Pular para o conteúdo
Node.js

J&T Express: rastreando e cotando frete pela API

Paloma Macetko
Ilustração escura e mágica: uma coruja carrega uma encomenda sobre um castelo com velas flutuantes, enquanto um unicórnio de crina luminosa caminha por uma esteira de triagem e uma varinha lê a etiqueta de um pacote

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

Exemplos_Frete_JETExpress no GitHubOs trechos deste artigo já montados num arquivo só, com as credenciais por variável de ambiente.github.com

🚚 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á devolve 145003010 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 apiAccount e assinatura) e existe o painel de rastreio em vip.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 com J).
  • 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:

  • scanNetworkTypeName vem 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.
  • signer e staffName (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. 😤

EnviadoResposta
"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:

scanCodescanTypeSignificado
10coleta de encomendaO entregador coletou o pacote com o remetente. Primeiro evento.
210bipe de coleta recebidaA coleta foi registrada na primeira unidade — o pacote entrou na rede.
50bipe de expediçãoSaiu de uma unidade rumo à próxima. nextStopName diz para onde. Repete várias vezes.
90 / 92bipe de recebimentoChegou a uma unidade. 90 é centro de distribuição, 92 é filial de entrega.
94bipe de saída para entregaSaiu para a entrega final. Repete se a entrega falhar e for retentada.
110bipe de pacote problemáticoInsucesso ou ocorrência. O motivo vem em problemType. Não é estado final.
100assinatura de encomendaEntregue. 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ê querEnvieResultado
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ódigoMensagemCausa provável
145003092The weight information is not legalPeso fora de 0,01–30 kg, ou enviado em gramas
145205005The insuredAmount information is not legalValor declarado com vírgula ou formato inválido
145003066Illegal receiver postcodeCEP de destino ausente ou malformado
145003031Business parameter signature verification failedFalta o digest interno, ou a Senha API está errada
145003030Headers signature verification failedprivateKey errada, ou digest do header mal gerado
145003010API account does not existapiAccount errado — ou você está no host de demo
999005030Invalid interwaybill addressPath errado. Veja abaixo.
145105023billcodes not existCampo 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:

PathDigest internoResposta
com /api/presente✅ a cotação
com /api/ausente145003031
sem /api/presente999005030

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 devolve success com details vazio.
  • 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:

Exemplos_Frete_JETExpress no GitHubnode jt.js rastrear <codigo> ou node jt.js cotar <origem> <destino> <peso>.github.com

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

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

Leia também