O fluxo em 4 chamadas
Uma venda paga na sua loja vira um envio despachado em quatro chamadas, nesta ordem. Cada uma depende do resultado da anterior.
id de cada serviço.
O preço nunca viaja no corpo da requisição. A cotação fica salva no
servidor e o /cart a referencia pelo id — se você
mandar um campo price, ele é descartado em silêncio. Isso é
proposital: o valor cobrado não pode depender do que o cliente envia.
Exemplo completo
# 1. cotar curl -X POST https://app.martinslog.net/api/v0/calculator \ -H "Authorization: Bearer frete_live_..." \ -H "Content-Type: application/json" \ -d '{"cepOrigem":"12321-313","cepDestino":"80010-000","formato":"CAIXA", "pesoRealG":1000,"alturaCm":20,"larguraCm":15,"comprimentoCm":10}' # devolve, entre outras: {"id":"clx123:svc-eco","price":"12.30", ...} # 2. criar o envio com o id da opção escolhida curl -X POST https://app.martinslog.net/api/v0/cart \ -H "Authorization: Bearer frete_live_..." \ -H "Content-Type: application/json" \ -d '{"service":"clx123:svc-eco","remetente":{...},"destinatario":{...}, "produtos":[{"nome":"Kit 4em1","quantidade":1,"valorUnitarioCentavos":9790}]}' # devolve: {"id":"shp_abc","price":"12.30","label_fee":"1.00","charged":false,"status":"PENDING"} # 3. pagar curl -X POST https://app.martinslog.net/api/v0/checkout \ -H "Authorization: Bearer frete_live_..." \ -H "Content-Type: application/json" \ -d '{"orders":["shp_abc"]}' # 4. ler o rastreio curl https://app.martinslog.net/api/v0/order/info/shp_abc \ -H "Authorization: Bearer frete_live_..."
Autenticação
Toda chamada de /api/v0 exige o token no cabeçalho:
Authorization: Bearer frete_live_a1b2c3...
O token é criado no painel, em Integrações, e pertence a uma conta. A carteira que paga o frete é a da conta dona do token — uma loja, um token.
| Prefixo | Ambiente | Efeito |
|---|---|---|
frete_test_ | Sandbox | Não encosta na carteira |
frete_live_ | Produção | Debita de verdade |
O token aparece uma única vez, na tela de criação. Só o hash fica gravado — não há como recuperá-lo depois, apenas revogar e criar outro.
Token ausente, malformado, inválido ou revogado responde 401 TOKEN_INVALIDO. O prefixo é rótulo, não credencial: a entropia inteira está nos 32 bytes que vêm depois dele.
Sandbox e produção
Mesma URL, mesmos campos, mesmas respostas. Só o token muda. Não existe host separado de teste.
| Comportamento | Sandbox | Produção |
|---|---|---|
| Débito na carteira | Nenhum lançamento é criado | Debita ao pagar |
| Código de rastreio | SANDBOX + 12 hex | EC000000014BR |
| Página pública de rastreio | Responde 200 e mostra erro | Abre o rastreio |
| Webhook | "sandbox": true — só em order.created e order.released |
Sem esse campo |
Campo charged | Sempre false | true após o pagamento |
Ele não vem em todos os eventos. Só order.created e
order.released são marcados. Um order.generated,
order.posted ou order.delivered de um envio de teste
chega idêntico a um real — e quem filtra teste por esse campo
registra a entrega de um pedido que nunca existiu.
Duas formas seguras: guarde o envio como teste quando o
order.created chegar marcado, ou consulte
GET /api/v0/order/info/:id, que traz sandbox sempre,
em qualquer estado.
Limite de requisições
60 requisições por minuto, por token. A contagem é por token, não por IP — uma loja não derruba a cota de outra que esteja atrás do mesmo servidor.
A janela é fixa, não deslizante: ela abre na primeira requisição e vale um minuto inteiro. Estourar no segundo 5 bloqueia os 55 segundos restantes — a cota não vai se liberando aos poucos. Distribua as chamadas em vez de disparar tudo de uma vez.
Estourando, a resposta é 429 LIMITE_REQUISICOES_EXCEDIDO
com o cabeçalho Retry-After: 60 e o tempo real que falta, em
segundos, na mensagem. Respeite o cabeçalho em vez de repetir imediatamente.
Uma venda consome 3 ou 4 chamadas. O teto prático é de cerca de 15 vendas por minuto por loja. Para importar histórico, enfileire — não existe endpoint em lote.
Erros
Todo erro tem a mesma forma:
{
"codigo": "SALDO_INSUFICIENTE",
"mensagem": "Saldo insuficiente para pagar este envio."
}
Erro de validação acrescenta campos, com a lista de problemas
por campo:
{
"codigo": "CORPO_INVALIDO",
"mensagem": "Dados de cotação inválidos.",
"campos": { "cepDestino": ["CEP de destino inválido"] }
}
Programe contra o codigo, nunca contra a mensagem —
o texto muda, o código não.
| HTTP | Código | Quando acontece | O que fazer |
|---|---|---|---|
| 400 | CORPO_INVALIDO | Campo faltando ou fora do formato | Corrigir e reenviar |
| 400 | CEP_INVALIDO | CEP não existe na base | Corrigir o endereço |
| 401 | TOKEN_INVALIDO | Token ausente, inválido ou revogado | Não repetir; conferir a credencial |
| 402 | SALDO_INSUFICIENTE | Carteira sem saldo no /checkout | Recarregar; o envio segue PENDING |
| 403 | NAO_AUTORIZADO | Ação fora do alcance do token | Não repetir |
| 404 | ENVIO_NAO_ENCONTRADO | Id inexistente ou de outra conta | Não repetir |
| 404 | COTACAO_NAO_ENCONTRADA | service aponta para cotação que sumiu | Cotar de novo |
| 422 | COTACAO_EXPIRADA | Cotação velha demais | Cotar de novo |
| 422 | TRANSICAO_INVALIDA | Ex.: pagar um envio já pago | Ler o status antes |
| 422 | ROTA_NAO_ATENDIDA | Sem serviço para o par de CEPs | Oferecer outra opção |
| 429 | LIMITE_REQUISICOES_EXCEDIDO | Mais de 60 chamadas no minuto | Esperar o Retry-After |
| 500 | ERRO_INTERNO | Falha inesperada nossa | Repetir com espera crescente |
404 em vez de 403 é decisão de segurança. Um id que existe em outra conta responde exatamente como um id que não existe — a diferença de código revelaria quais ids são reais.
Os três números
Confundi-los é o erro de integração mais caro. São valores diferentes, com donos diferentes.
| Campo | O que é | Quem paga |
|---|---|---|
price |
O frete — o transporte em si | O comprador da sua loja, no checkout dela |
label_fee |
O que a plataforma cobra por etiqueta gerada — hoje R$ 1,00 fixo | Você, lojista, da sua carteira |
charged |
Se a taxa saiu de fato da carteira | — |
Todos os valores monetários são strings em reais com duas casas
("12.30"), nunca centavos e nunca número. Nos campos de
entrada vale o contrário: valorUnitarioCentavos é inteiro
em centavos. A assimetria é real; não deduza uma coisa da outra.
charged é lido do livro-caixa, não do status nem da flag de
sandbox. Some label_fee apenas onde charged for
true, ou você vai contabilizar dinheiro que nunca saiu.
A taxa é por etiqueta, não proporcional ao frete: uma
encomenda de R$ 12,30 e outra de R$ 180,00 custam o mesmo R$ 1,00 a você.
O price aparece na etiqueta e no rastreio; o
label_fee aparece no seu extrato. Nunca cobre o
label_fee do comprador — ele não é frete.
1 · Cotação
Corpo
| Campo | Tipo | Regra | |
|---|---|---|---|
cepOrigem | string | obrig. | 00000-000 ou 00000000 |
cepDestino | string | obrig. | idem |
formato | enum | obrig. | CAIXA · ROLO · ENVELOPE |
pesoRealG | inteiro | obrig. | gramas, > 0 |
alturaCm | inteiro | obrig. | centímetros, > 0 |
larguraCm | inteiro | obrig. | centímetros, > 0 |
comprimentoCm | inteiro | obrig. | centímetros, > 0 |
Medidas são inteiras. 20.5 é recusado com
CORPO_INVALIDO — arredonde para cima antes de enviar.
Resposta 200
[
{
"id": "clx123abc:svc-economico",
"name": "Econômico",
"price": "12.30",
"discount": "3.70",
"delivery_time": 5,
"company": { "name": "Martins Log" }
}
]
id— no formatoquoteId:servicoId. É o que vai noservicedo passo 2, inteiro e sem alterações.-
delivery_time— prazo em dias corridos, e a contagem começa na emissão da etiqueta (o eventoorder.generated), não na postagem nem na compra. Sábado, domingo e feriado contam. Se a sua loja anuncia o prazo em dias úteis, converta antes de mostrar ao comprador — são números diferentes. - Serviços indisponíveis para a rota não aparecem. Lista vazia é resposta válida e significa "não atendemos esse trecho" — trate na sua tela, não como erro.
2 · Criar envio
Corpo
| Campo | Tipo | Regra | |
|---|---|---|---|
service | string | obrig. | o id vindo da cotação |
remetente | endereço | obrig. | tabela abaixo |
destinatario | endereço | obrig. | tabela abaixo |
produtos | lista | obrig. | ao menos um item |
Endereço
| Campo | Tipo | Regra | |
|---|---|---|---|
nome | string | obrig. | não vazio |
cep | string | obrig. | 00000-000 ou 00000000 |
logradouro | string | obrig. | não vazio |
numero | string | obrig. | string, não número — "S/N" é válido |
bairro | string | obrig. | não vazio |
cidade | string | obrig. | não vazio |
uf | string | obrig. | exatamente 2 letras |
documento | string | opc. | CPF ou CNPJ |
email | string | opc. | e-mail válido ou string vazia |
telefone | string | opc. | — |
complemento | string | opc. | — |
email tem uma pegadinha: se o campo existir, precisa ser um
e-mail válido ou exatamente "". Mandar
null ou "—" derruba a requisição inteira. Se não tem
e-mail, omita a chave.
Produto
| Campo | Tipo | Regra | |
|---|---|---|---|
nome | string | obrig. | não vazio |
quantidade | inteiro | obrig. | > 0 |
valorUnitarioCentavos | inteiro | obrig. | centavos, > 0 — 9790 = R$ 97,90 |
A soma dos produtos vira o valor declarado do envio, que é a base do seguro. Declarar menos que o real reduz a cobertura.
Resposta 201
{
"id": "shp_9f2c...",
"price": "12.30",
"label_fee": "1.00",
"charged": false,
"status": "PENDING"
}
Guarde o id no seu pedido agora. Ele é a única
chave que liga a sua venda ao envio, e é o que impede a duplicação
descrita no checklist.
3 · Pagar
Debita a carteira e, em produção, emite a etiqueta na sequência. Você não precisa chamar mais nada para obter o código de rastreio.
Corpo
{ "orders": ["shp_9f2c..."] }
Aceita vários ids, mas o processamento é sequencial e sem transação: se o terceiro falhar por saldo, os dois primeiros já foram pagos e continuam pagos. Mandar um id por chamada torna o tratamento de erro muito mais simples.
Resposta 200
{
"success": true,
"purchase": {
"status": "approved",
"orders": [ { "id": "shp_9f2c...", "status": "GENERATED" } ]
}
}
O status de cada ordem é relido do banco depois do pagamento.
Ele costuma vir GENERATED — a etiqueta já saiu —, mas pode vir
RELEASED se a emissão ainda não concluiu. Não assuma nenhum dos
dois: use o valor devolvido, ou espere o webhook
order.generated.
Sem saldo, a resposta é 402 SALDO_INSUFICIENTE e o
envio permanece PENDING — nada se perde. Basta recarregar e
chamar de novo com o mesmo id.
4 · Consultar
{
"id": "shp_9f2c...",
"status": "POSTED",
"tracking": "EC000000014BR",
"tracking_url": "/r/EC000000014BR",
"price": "12.30",
"label_fee": "1.00",
"charged": true,
"sandbox": false,
"returned_at": null, // preenchido = voltou ao remetente
"created_at": "2026-09-01T10:00:00.000Z"
}
Vem como /r/EC000000014BR, sem domínio. Concatene com
https://app.martinslog.net antes de mostrar ao cliente. Mandar o
caminho cru num e-mail gera um link quebrado.
tracking e tracking_url são null enquanto o
envio não tem código — na prática, antes de GENERATED.
Datas são sempre ISO 8601 em UTC.
Ciclo de vida do envio
Sete estados. As transições são verificadas no servidor: tentar um salto inválido devolve 422 TRANSICAO_INVALIDA.
| Status | Significa | Pode ir para |
|---|---|---|
PENDING | Criado, não pago | RELEASED · CANCELLED |
RELEASED | Pago, etiqueta em emissão | GENERATED · CANCELLED |
GENERATED | Etiqueta emitida, já tem código | POSTED · CANCELLED |
POSTED | Coletado, em trânsito | DELIVERED · LOST |
DELIVERED | Entregue | — final |
CANCELLED | Cancelado | — final |
LOST | Extraviado | — final |
Cancelamento só é possível até GENERATED. Depois de
POSTED a carga está na rua e não há mais o que cancelar.
LOST não dispara evento nenhum. Extravio não é
cancelamento, e mandar order.cancelled descreveria errado o que
houve com a carga. Se o extravio importa para você, consulte o status.
Devolução ao remetente também chega como order.delivered.
A carga foi entregue — de volta a quem despachou. Pelo webhook os dois casos são
idênticos: confirme em GET /api/v0/order/info/:id, onde
returned_at preenchido significa que o pacote voltou. Sem essa
conferência, a loja marca como entregue ao comprador um pacote que está de volta
no estoque dela.
Webhooks
A alternativa a ficar consultando /order/info. Nós avisamos a cada
mudança de estado.
POST /api/v0/webhook exige sessão do painel, não
aceita o token de API. Quem cadastra a URL é o dono da loja, logado em
Integrações. Não há como automatizar isso do seu lado.
Regras da URL de destino
httpsobrigatório —httpé recusado.- Sem usuário e senha embutidos na URL.
- Sem
localhost,.localhost,.internalou IP de rede privada.
URL recusada devolve 422 ARQUIVO_INVALIDO com o motivo.
Eventos
| Evento | Disparado quando | tracking |
|---|---|---|
order.created | O envio é criado (/cart) | null |
order.released | O pagamento é confirmado | null |
order.generated | A etiqueta sai — o código nasce aqui | preenchido |
order.posted | A carga é coletada | preenchido |
order.delivered | A entrega é concluída | preenchido |
order.cancelled | O envio é cancelado | varia |
Se você só puder escutar um, escute order.generated —
é onde o código de rastreio aparece.
Corpo entregue
{
"event": "order.generated",
"data": {
"id": "shp_9f2c...",
"status": "GENERATED",
"tracking": "EC000000014BR",
"tracking_url": "/r/EC000000014BR", // relativo
"price": "12.30", // string, em reais
"created_at": "2026-09-01T10:00:00.000Z"
},
"sent_at": "2026-09-01T10:00:01.000Z"
}
// `sandbox: true` entra no mesmo nível de `event` — mas SÓ em
// order.created e order.released. Este exemplo é order.generated,
// que nunca traz o campo, nem quando o envio é de teste.
Entrega e retentativa
| Parâmetro | Valor |
|---|---|
| Atraso até a primeira tentativa | até 1 minuto |
| Tempo limite por tentativa | 5 segundos |
| Tentativas totais | 6 (1 imediata + 5 reagendadas) |
| Intervalos | 1 min · 5 min · 30 min · 2 h · 12 h |
| Janela total | ≈ 14 horas |
| Repete em | falha de rede · 408 · 429 · 5xx |
| Desiste em | qualquer outro 4xx |
A fila é processada por um agendador que roda a cada minuto. Um evento não sai no mesmo instante em que acontece: espere até um minuto entre a mudança de status e a chegada da entrega. Não é fila travada.
Responda rápido e com 2xx. Grave o evento numa fila sua e processe depois; passar de 5 segundos conta como falha e gera reentrega. Um 404 (rota errada) faz o sistema desistir na primeira tentativa — o que é proposital: repetir contra uma URL inexistente por catorze horas só martelaria o seu servidor.
Entregas podem chegar fora de ordem ou repetidas. Trate cada
uma como idempotente: ignore um order.posted que chegue depois de
um order.delivered já processado.
Verificar a assinatura
Toda entrega vai assinada. Dois cabeçalhos:
| Cabeçalho | Conteúdo |
|---|---|
x-frete-signature | sha256=<hex> |
x-frete-timestamp | segundos desde a época Unix |
A assinatura é o HMAC-SHA256 de <timestamp>.<corpo>,
com o segredo devolvido no cadastro. O timestamp entra dentro
do que é assinado — por isso alterá-lo invalida a assinatura, e uma
requisição capturada não serve para sempre.
1. Use o corpo cru, antes de qualquer
JSON.parse. Reserializar muda espaços e ordem de chaves, e a
assinatura não fecha.
2. Rejeite timestamp fora de 5 minutos. Sem isso, quem capturar uma entrega pode reenviá-la para sempre.
3. Compare em tempo constante. Com
===, o tempo de resposta revela quantos bytes iniciais o
atacante acertou.
Node.js
const { createHmac, timingSafeEqual } = require('crypto') function verificar(segredo, corpoCru, assinatura, timestamp) { // 1. janela de 5 minutos if (!/^\d+$/.test(timestamp)) return false const agora = Math.floor(Date.now() / 1000) if (Math.abs(agora - Number(timestamp)) > 300) return false // 2. o timestamp entra no que é assinado const esperada = createHmac('sha256', segredo) .update(`${timestamp}.${corpoCru}`) .digest('hex') if (!assinatura.startsWith('sha256=')) return false const recebida = assinatura.slice('sha256='.length) if (recebida.length !== esperada.length) return false // 3. comparação em tempo constante return timingSafeEqual( Buffer.from(recebida, 'hex'), Buffer.from(esperada, 'hex'), ) }
PHP
function verificar($segredo, $corpoCru, $assinatura, $timestamp) { if (!ctype_digit($timestamp)) return false; if (abs(time() - (int)$timestamp) > 300) return false; $esperada = 'sha256=' . hash_hmac('sha256', "$timestamp.$corpoCru", $segredo); return hash_equals($esperada, $assinatura); } // o corpo cru, nunca $_POST $corpoCru = file_get_contents('php://input');
Ele é devolvido apenas na resposta do cadastro. A listagem de webhooks nunca o traz de volta — se fosse legível depois, qualquer falha de autorização em uma tela permitiria forjar entregas assinadas. Perdeu? Cadastre outro webhook.
Rastreio
Formato do código
EC 00000001 4 BR — duas letras de serviço, oito
dígitos sequenciais, um dígito verificador módulo 11 e o sufixo do país.
O formato segue a convenção dos Correios para que leitores de código de
barras e planilhas já existentes o aceitem; o código em si é nosso.
Em sandbox o formato é outro: SANDBOX seguido de 12 caracteres hexadecimais.
Ele não passa na validação de formato: a consulta responde 422 CODIGO_INVALIDO e a página mostra erro. A página em si devolve HTTP 200 — ela é montada no navegador, e o erro aparece depois da consulta. Não use o código de status da página como verificação de saúde: 200 ali não significa que o rastreio existe.
Enquanto o token for frete_test_, todo link de rastreio que a sua
loja mandar ao comprador vai parecer quebrado. É esperado, não é falha da
integração.
Página para o seu cliente
https://app.martinslog.net/r/EC000000014BR
Aberta, sem login, e não mostra nome, documento nem endereço de ninguém — só serviço, situação e cidade/UF de cada etapa. É o link seguro para pôr no e-mail de confirmação.
Consulta programática
Não exige autenticação, e por isso tem limite próprio:
30 consultas a cada 5 minutos por IP, com
Retry-After na resposta 429.
| HTTP | Código | Significa |
|---|---|---|
| 200 | — | { "rastreio": { … } } |
| 422 | CODIGO_INVALIDO | Formato ou dígito verificador errado |
| 404 | ENVIO_NAO_ENCONTRADO | Código bem formado, envio inexistente |
| 429 | LIMITE_CONSULTAS_EXCEDIDO | Limite por IP estourado |
O 422 é útil: significa que o código foi digitado errado e dá para corrigir. O 404 significa que o código é plausível mas não existe.
Cada evento traz sequencia, codigo, titulo,
descricao, cidade, uf e ocorridoEm.
Os códigos possíveis são ETIQUETA_EMITIDA, POSTADO,
TRANSFERENCIA, AGUARDANDO_TRATAMENTO,
SAIU_PARA_ENTREGA, TENTATIVA_FRUSTRADA,
AGUARDANDO_RETIRADA, ENTREGUE, EXTRAVIADO,
DEVOLUCAO_INICIADA e DEVOLVIDO.
Checklist de checkout
O que separa uma integração que funciona de uma que cobra duas vezes.
Grave o id do envio antes de qualquer outra coisa
Assim que o /cart responder, guarde o id no seu pedido e
só chame o /cart quando esse campo estiver vazio. Duas
chamadas para a mesma venda criam dois envios e duas cobranças na sua
carteira.
Dispare a partir do pagamento confirmado, não do clique
O gatilho é o webhook do seu gateway confirmando o PIX ou o cartão. Criar o envio no clique de "finalizar compra" gera etiqueta para venda que não foi paga.
Trate 402 como estado, não como erro
Saldo insuficiente é rotina. O envio fica PENDING e nada se perde:
avise o lojista, e tente de novo com o mesmo id depois da recarga. Não
deixe o pedido travado em silêncio.
Prefira o webhook ao polling
Consultar /order/info de minuto em minuto consome a cota de 60/min
que você vai precisar para vender. Use o webhook e reserve a consulta para
conferência.
Confira sandbox em toda entrada
Tanto no webhook quanto no /order/info. Um pedido de teste tratado
como venda real gera código de rastreio que não abre para o cliente.
Nunca mande preço
Não existe campo de preço em nenhum corpo de requisição. Se você enviar um, ele é descartado sem erro — e você vai passar horas procurando por que o valor "não foi aplicado".
O que ainda não existe
Limites reais da versão v0, listados para que você não os descubra em produção.
| Limitação | Consequência | Como contornar |
|---|---|---|
| Sem chave de idempotência | /cart não aceita referência externa. Um campo external_id é descartado em silêncio. |
Deduplicar do seu lado, pelo id do envio gravado no pedido. |
| Sem endpoint em lote | Importação de histórico é uma chamada por envio. | Enfileirar respeitando 60/min. |
| Cadastro de webhook exige sessão | Não dá para provisionar webhook por API. | O lojista cadastra no painel, uma vez. |
| Saldo da carteira não é exposto | Não há como checar saldo antes de faturar. | Tratar o 402 como caso esperado. |
| Não há PDF de etiqueta | A rota de etiqueta é de sessão e devolve só o código. | O /checkout já emite; imprimir é pelo painel. |
tracking_url é relativo |
Link quebrado se usado direto. | Prefixar com a URL base. |
LOST não tem evento |
Extravio chega como order.cancelled. |
Consultar o status quando o evento chegar. |
MARTINS LOG E TRANSPORTES LTDA · Documento gerado a partir do código-fonte da plataforma. Endpoints, códigos de erro e limites verificados contra a API em produção.