Referência da API

Todo o comportamento da API, e por que cada decisão é o que é. O guia rápido é a versão de uma tela, e o spec legível por máquina está em /openapi.json: o mesmo documento que o playground Swagger renderiza.

O contrato inteiro: autenticação, as duas formas de requisição, os endpoints vizinhos, o formato do relatório campo a campo, todos os códigos de erro e os limites em volta deles. Se você ainda não fez nenhuma chamada, o guia rápido cabe em uma tela e chega lá mais rápido. A especificação legível por máquina, com console de teste, está em /docs (em inglês).

1. Autenticação

Um bearer token no header Authorization. As chaves têm o prefixo tl_.

Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Três comportamentos que vale conhecer, porque são deliberados:

  • Sem header → anônimo. Permitido, com rate limit por IP.
  • Um header que não resolve → 401 na hora. Uma chave apresentada e inválida nunca degrada silenciosamente para anônimo, porque é essa degradação silenciosa que transforma uma chave rotacionada em produção num mistério em vez de num erro.
  • Uma chave válida → medida contra a cota mensal da conta dona da chave (todas as chaves da conta dividem um único teto; rotacionar a chave não o zera), e totalmente isenta do rate limit por IP dos anônimos.

O endpoint de análise autentica só por header, nunca por cookie, então não há nada a configurar em CORS com credenciais e nada com cara de CSRF para se preocupar. Os endpoints de conta e cobrança são o oposto (cookie de sessão, corpo JSON obrigatório), e estão documentados em /docs.

2. Fazendo uma requisição

POST /api/v1/inspect aceita multipart/form-data com um campo chamado file, ou um corpo bruto. As duas formas são equivalentes; escolha a que for mais barata no seu cliente HTTP. O limite é 10 MB.

Três mídias, um endpoint e um formato de relatório. PDFs; documentos do Word, Excel e PowerPoint (.docx, .xlsx, .pptx e os equivalentes com macro); e imagens JPEG, PNG, WebP, HEIC/HEIF e AVIF. TIFF, GIF e BMP são reconhecidos e reportados, mas seus contêineres não são percorridos, e o relatório diz isso num sinal, porque um resultado vazio nunca pode ser confundido com um resultado limpo.

O formato é decidido farejando os bytes, nunca pelo cabeçalho Content-Type nem pelo nome do arquivo. Os relatórios trazem um discriminador mediaType ("pdf", "office" ou "image"), mais um bloco aditivo office ou image; document continua preenchido nos três casos, então uma integração existente continua funcionando.

multipart/form-data

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected]

corpo bruto application/pdf

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/pdf" \
  --data-binary @extrato.pdf

Node: corpo bruto, sem encoder de multipart

const pdf = await readFile("extrato.pdf");

const res = await fetch("https://tamperlens.com/api/v1/inspect", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TAMPERLENS_KEY}`,
    "Content-Type": "application/pdf",
  },
  body: pdf,
});

if (!res.ok) throw new Error(`inspect falhou: ${res.status}`);
const report = await res.json();

if (report.summary.riskBand !== "low") {
  await enviarParaAnaliseManual(applicationId, report.signals);
}

Python

import os, requests

with open("extrato.pdf", "rb") as fh:
    res = requests.post(
        "https://tamperlens.com/api/v1/inspect",
        headers={
            "Authorization": f"Bearer {os.environ['TAMPERLENS_KEY']}",
            "Content-Type": "application/pdf",
        },
        data=fh.read(),
        timeout=30,
    )

res.raise_for_status()
report = res.json()
print(report["summary"]["riskBand"], [s["id"] for s in report["signals"]])

Verificando um arquivo contra o original: /compare

Quando você tem o original em mãos: você gerou o documento, ou guardou a cópia que o emissor enviou: POST /api/v1/compare responde a uma pergunta mais forte do que a análise isolada consegue: este arquivo é aquele original, sem alterações? Envie dois arquivos e receba os fatos em três camadas, da mais forte para a mais fraca.

Comparar um arquivo reenviado com o que está em registro

curl -s https://tamperlens.com/api/v1/compare \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected] \
  -F [email protected]
  • relationship: "identical". Os arquivos têm o mesmo hash. A única resposta que é uma prova, e não um sinal.
  • "candidate-extends-original": o candidato contém o original byte a byte, mais atualizações incrementais anexadas. O relatório classifica o que as revisões anexadas tocam; ancestry.contentTouched é o fato principal. O conteúdo da página foi substituído depois que o original estava completo. "candidate-truncates-original" é o inverso: o candidato é uma revisão anterior do original.
  • "rewritten", nenhum contém o outro, e a comparação recua com honestidade para a estrutura interpretada: metadados campo a campo, o par /ID, fontes, número e geometria das páginas. Nenhuma página é renderizada, então nenhum resultado afirma que a aparência visual é idêntica.

O /compare exige uma chave (não há acesso anônimo) e interpreta dois documentos, então é medido como 2 documentos na cota mensal. Apenas pares de PDF. O schema completo está em /docs, e o guia explica o que cada relação prova, e o que não prova.

Vários documentos do mesmo caso: /case

Quem concede crédito quase nunca recebe um documento só. Recebe três meses de extrato, ou um holerite e o extrato onde ele caiu. Três "meses" feitos editando um mesmo extrato verdadeiro passam, cada um, numa análise isolada; lado a lado, ainda carregam os identificadores daquele arquivo. POST /api/v1/case recebe de 2 a 10 arquivos do mesmo caso, analisa cada um exatamente como o /inspect faria e aponta o que os arquivos têm em comum e documentos independentes não deveriam ter. A retenção vale por documento, como no /inspect (§8).

Três extratos, uma chamada

curl -s https://tamperlens.com/api/v1/case \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected] \
  -F [email protected] \
  -F [email protected]

A resposta é { reports, crossDocument }. reports[i] é, na ordem do envio, o laudo que o /inspect devolve para o arquivo i, com o próprio receipt quando os recibos estão ligados. Um arquivo que não pôde ser analisado recebe o erro que o /inspect daria para ele, mais status, o status HTTP desse erro sozinho (not_a_pdf 422, analysis_timeout 422, busy 503, case_budget_exhausted 503, internal_error 500); os outros arquivos voltam normalmente. crossDocument.findings lista { code, documents, evidence }, em que documents são índices em reports e evidence traz hashes, identificadores, contagens e datas, nunca texto da página.

  • same-file: dois envios têm o mesmo document.sha256. Causa inocente: o mesmo extrato mandado duas vezes.
  • same-base-file: arquivos diferentes compartilham a metade permanente do /ID do trailer (evidence.basis inclui id-permanent), ou o mesmo conjunto inteiro de duas ou mais tags de subconjunto de fonte não triviais (font-subset-tags). Editar um arquivo para fazer outro mantém as duas coisas. Causas inocentes: um gerador que repete o mesmo /ID em todo arquivo que escreve, um gerador que numera as tags em vez de sorteá-las (o Chrome escreve BAAAAA, CAAAAA e assim por diante, por isso essas ficam de fora da comparação), ou um documento salvo de novo pelo próprio emissor. Geradores da família TeX derivam as tags dos glifos usados, então as tags deles não são comparadas. Só PDF.
  • shared-creation-instant: PDFs diferentes dizem ter sido criados no mesmo segundo (em UTC). Causa inocente: um banco que exporta vários meses de uma vez. Meia-noite nunca é comparada, porque uma data escrita sem hora vira meia-noite.

São inconsistências para revisar, não veredictos, e nenhuma muda a nota de nenhum laudo. Nenhuma regra usa sozinha o fingerprint.issuerProfileHash: extratos verdadeiros do mesmo banco o compartilham todo mês, então três extratos reais do mesmo emissor não levantam nada. Um caso sem achados não prova que os documentos são independentes.

O /case exige uma chave. Cada documento analisado conta como um documento, então um caso de N custa N, e um arquivo que falhou não custa nada. A conta precisa ter N documentos disponíveis quando os arquivos terminam de chegar, senão a chamada recebe 429 quota_exceeded. Cada arquivo respeita o limite de envio; somados, não podem passar de três vezes esse limite (413 case_too_large). Mais de 10 arquivos dá 400 too_many_files, menos de 2 dá 400 not_enough_files. Os arquivos rodam um por vez, cada um com o prazo de um documento, dentro de um orçamento de 90 segundos para o caso inteiro: um arquivo só começa se ainda sobra um prazo inteiro mais 5 segundos de folga, e um arquivo que ainda espera um worker quando o orçamento acaba é deixado de lado. Nos dois casos o slot diz case_budget_exhausted e o arquivo não é cobrado. No máximo dois casos rodam ao mesmo tempo num servidor, e um dos dois fica reservado para planos pagos: uma conta grátis roda um caso por vez no servidor, e nenhum enquanto um caso pago ocupa o outro. O caso só ocupa a vaga depois que todos os arquivos chegaram, então o 503 busy por capacidade pode vir depois do envio; ele traz Retry-After, a mesma resposta de um /inspect recusado por carga. Enquanto os arquivos chegam, os bytes de todos os casos também têm teto (o limite combinado de dois casos, um deles reservado para planos pagos), e acima dele a chamada também recebe 503 busy. Uma conta, e uma mesma rede, roda um caso por vez: um segundo recebe 429 busy com Retry-After. Os arquivos têm 30 segundos para chegar, contados de quando o caso é admitido; depois disso a resposta é 408 body_timeout e a conexão é fechada. Nada disso é cobrado. O ?redact=payload e o ?changes=values valem para cada laudo como no /inspect.

Lendo os metadados de um arquivo: /metadata

O POST /api/v1/metadata responde a uma pergunta diferente da inspeção: não isto foi editado?, mas o que este arquivo diz sobre si mesmo? Todas as chaves, agrupadas pelo contêiner de onde vieram, com a forma armazenada ao lado da decodificada.

Liste tudo o que um documento carrega

curl -s https://tamperlens.com/api/v1/metadata \
  -F [email protected]
  • PDF: o dicionário Info completo, não só os cinco campos que as famílias de sinais leem. Author, Subject, Keywords e qualquer chave personalizada inventada por um sistema aparecem todas, além de todas as propriedades XMP e dos fatos estruturais (páginas, revisões, criptografia, assinaturas, fontes).
  • Imagens: EXIF separado por diretório, para que os fatos da miniatura continuem distinguíveis dos da fotografia; coordenadas de GPS decodificadas; blocos de texto de PNG; XMP. Contêineres presentes mas não enumerados (IPTC, ICC, MakerNote, C2PA) são nomeados em vez de omitidos em silêncio.
  • Office: propriedades core, de aplicação e personalizadas, e o EXIF de cada imagem embutida no pacote. Um .docx é um ZIP: uma foto colada nele mantém as coordenadas que a câmera escreveu, e isso costuma ser o mais surpreendente do relatório.

O campo personal reúne as entradas que identificam uma pessoa, um aparelho ou um lugar: nomes, números de série, programas, localizações, como cópia, para que a lista agrupada continue completa. Não há sinais, nota de risco nem veredicto; isso é trabalho do /inspect.

Somente leitura, por construção. A resposta é JSON e nada além de JSON. A Tamperlens não tem nenhum endpoint que devolva um arquivo modificado (nem cópia limpa, nem download sem metadados), porque produzir um significaria escrever um documento, e todas as promessas da página de segurança dependem de nunca fazermos isso. Se você precisa de um arquivo limpo, limpe localmente; nós dizemos exatamente o que remover.

Um arquivo, uma leitura, medido como 1 documento. Funciona anonimamente como o verificador gratuito, então a chave é opcional. A versão no navegador está em /pt/metadados.

Health check, sem autenticação: GET /api/v1/health.

Para agentes: cheque o documento antes que o seu agente o leia

Um agente que abre um anexo já leu tudo o que o anexo diz. Um PDF ou um .docx é um lugar cômodo para escrever uma frase endereçada ao modelo, e não à pessoa, e um pipeline de extração entrega justamente os campos que nenhum leitor humano olha. A Tamperlens classifica esses portadores na entrada: valores do dicionário Info, inclusive as chaves personalizadas que cada pipeline inventa, o pacote XMP, o conteúdo de anotações, nomes e descrições de arquivos embutidos e, no lado Office, docProps, propriedades personalizadas, texto de comentários e trechos marcados como ocultos. A mesma leitura reporta ao lado a superfície executável (JavaScript embutido, ações /Launch e arquivos anexos ), e a Tamperlens nunca executa, abre ou segue nenhuma delas.

A ordem útil, então, é checar e só depois ler: uma chamada antes de os bytes chegarem ao contexto do seu modelo, ramificando em document-injection-markers e office-injection-markers como em qualquer outra família. O payload não precisa viajar junto com o achado, ?redact=payload devolve o mesmo relatório com toda string escrita pelo atacante elidida.

O que isto não lê, dito para você não supor. O classificador roda nos portadores acima, não no conteúdo de página de um PDF: texto escondido dentro do content stream não é checado em busca de instruções, porque separar uma mensagem oculta da camada de OCR invisível sob uma digitalização é trabalho especificado e não construído. Um relatório limpo aqui quer dizer que aqueles campos estavam limpos, não que o documento é seguro.

Se quem chama é um agente de LLM e não o seu próprio código, existe um servidor MCP que expõe os mesmos endpoints como cinco ferramentas: inspect_document, triage_document, check_redaction, compare_documents e inspect_case. Ele é um invólucro fino sobre a API HTTP desta página; não há um segundo motor atrás dele.

{
  "mcpServers": {
    "tamperlens": {
      "command": "node",
      "args": ["/caminho/absoluto/para/tamperlens/dist/mcp/index.js"],
      "env": { "TAMPERLENS_API_KEY": "tl_..." }
    }
  }
}

As ferramentas recebem um caminho absoluto de arquivo, nunca base64 embutido: os bytes precisam chegar à Tamperlens, não ao modelo, e passar um documento pelo contexto de um agente custaria megabytes à toa.

Está no npm. A configuração acima continua valendo para quem prefere fixar um build: clone o repositório, rode npm run build e aponte o seu host MCP para dist/mcp/index.js. Para todo o resto:

npx tamperlens-mcp

O motivo de ter demorado a ser publicável é que este repositório carrega o servidor da API, então instalá-lo compilaria um driver de banco nativo para um cliente que só fala HTTP. Isso está resolvido: packages/mcp gera o tamperlens-mcp com duas dependências de execução, o SDK do MCP e o zod, e instala num projeto limpo sem nenhum build nativo.

A versão dele é a versão do motor, de propósito: o [email protected] diz qual motor ele conversa, em vez de contar as próprias publicações. Ele é um cliente HTTP, não carrega nenhuma lógica de sinal, e uma versão nova do motor chega até você quando esta API é publicada, não quando você atualiza o pacote.

3. Baselines de emissor

As vinte famílias de sinais acima respondem a uma pergunta: este arquivo foi alterado depois de gerado? Elas nada dizem sobre um documento fabricado do zero: gerado uma única vez a partir de um modelo e nunca mais tocado. Um arquivo assim é estruturalmente impecável, então nada dispara.

Um baseline de emissor responde à outra pergunta: este arquivo se parece com o que a instituição citada realmente emite? Toda instituição gera seus PDFs com uma cadeia de ferramentas fixa, e essa cadeia deixa uma impressão digital estrutural consistente: o mesmo produtor, a mesma versão de PDF, o mesmo conjunto de fontes incorporadas, a mesma geometria de página, o mesmo estilo de tabela de referências cruzadas, uma única revisão. Um extrato falso gerado com wkhtmltopdf é internamente coerente, mas errado para o banco cujo nome está nele, e isso é visível sem ler um único número da página.

O que um baseline guarda

Um perfil é apenas estrutura derivada: ele registra que os documentos de um emissor trazem o produtor X e a versão de PDF 1.4. Ele nunca contém os documentos, o texto deles, números de conta, nomes ou saldos, e ocupa algumas centenas de bytes. Os documentos de treino são lidos em memória e descartados exatamente como em uma análise, de modo que o treino nunca guarda um documento, seja qual for a retenção da conta.

Informando o emissor em uma análise

Opcional e aditivo. Envie o slug do emissor como um campo multipart issuer (antes da parte do arquivo) ou como o cabeçalho X-Tamperlens-Issuer em um corpo cru. Os slugs seguem ^[a-z0-9][a-z0-9-]{0,63}$.

multipart

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F issuer=chase \
  -F [email protected]

corpo cru

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/pdf" \
  -H "X-Tamperlens-Issuer: chase" \
  --data-binary @extrato.pdf

A resposta é o relatório de sempre mais um bloco no nível raiz. Uma divergência confiável também levanta um sinal issuer-mismatch em signals.

{
  "issuerBaseline": {
    "requested": "chase",
    "resolved": true,
    "source": "customer",
    "sampleCount": 18,
    "matchingActive": true,
    "divergence": 0.42,
    "mismatchedDimensions": ["producers", "fontSets"]
  }
}

Um slug desconhecido não é erro. Você recebe o relatório normal e {"requested": "chase", "resolved": false, "reason": "no_profile"}, para que “combinou” e “não havia com o que comparar” nunca se confundam: uma distinção que a ausência de um sinal não consegue expressar. Um slug malformado é 422 invalid_issuer, porque só pode ser um bug de quem chama. Omita issuer e a resposta é byte a byte a mesma de antes deste recurso existir: a chave nem aparece.

Treinando os seus

Os baselines que você treina com seus próprios documentos comprovadamente legítimos são privados da sua conta e sempre vencem os que já vêm incluídos. Um pequeno conjunto de perfis públicos de bancos acompanha o produto como alternativa. Perfis nunca são combinados entre contas.

curl -s https://tamperlens.com/api/v1/baselines \
  -H "Cookie: tl_session=$SESSION" \
  -F issuer=chase \
  -F label="JPMorgan Chase" \
  -F [email protected] -F [email protected] -F [email protected] \
  -F [email protected] -F [email protected] -F [email protected]
  • GET /api/v1/baselines lista os seus perfis e os incluídos, com builtIn separando uns dos outros e um índice de consistency por dimensão.
  • DELETE /api/v1/baselines/:issuer remove um dos seus. Um perfil incluído é compartilhado e responde 404.
  • Cinco documentos legítimos é o piso para que um perfil consiga apontar uma divergência, e uma dimensão em que os seus documentos discordam simplesmente não gera expectativa. Treinar o mesmo emissor de novo funde com o perfil existente, então um baseline pode crescer em lotes.
  • Treinar não consome cota de análise. O limite é outro: 25 documentos por requisição, 25 perfis por conta, 5 treinos por hora.

Estes três endpoints autenticam por sessão do painel, e não por chave de API. O jeito mais simples de usá-los é pelo painel.

4. Sua política de veredicto

A Tamperlens não decide se um documento é fraudulento, e a seção 9 explica por que isso é uma posição, e não uma esquiva. Mas você pode ter decidido, e, antes disto existir, precisava reimplementar essa decisão do seu lado: cada integrador escrevendo o mesmo if (riskScore >= 70) de um jeito ligeiramente diferente, nada disso visível para nós quando você perguntava por que um documento tirou a nota que tirou.

Então mande a regra junto com a requisição. As mesmas duas grafias do emissor: um campo policy no multipart, antes da parte do arquivo, ou um cabeçalho X-Tamperlens-Policy. Os dois carregam um objeto JSON.

curl -X POST https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer $TL_KEY" \
  -H 'X-Tamperlens-Policy: {"review":30,"reject":70,"rejectOn":["redaction-exposure"]}' \
  -H "Content-Type: application/pdf" \
  --data-binary @extrato.pdf

A resposta ganha um bloco, e nada mais muda:

"policy": {
  "verdict": "reject",          // accept | review | reject
  "riskScore": 45,              // a nota do próprio relatório, nunca recalculada
  "thresholds": { "review": 30, "reject": 70 },
  "reason": "signal",           // "score" quando um corte foi cruzado
  "triggeredBy": ["redaction-exposure"],
  "policyVersion": "pv1-ba2f7738e0ae" // nomeia a regra que decidiu, veja abaixo
}
Campo Padrão O que faz
review 30 Nota a partir da qual o veredicto é review
reject 70 Nota a partir da qual o veredicto é reject
rejectOn [] Famílias de sinais que forçam reject, qualquer que seja a nota
reviewOn [] Famílias de sinais que forçam pelo menos review
ignore [] Famílias de sinais que ficam fora da decisão. Continuam no relatório
preset nenhum strict, balanced ou lenient: um ponto de partida que as suas chaves sobrescrevem

Os cortes padrão são as próprias faixas do motor, então uma política sem configuração concorda com summary.riskBand em vez de discordar dele em silêncio. Uma família nomeada vence um corte, e rejectOn vence reviewOn, quem nomeia uma família está dizendo que ela importa independentemente do que a aritmética deu, e um corte capaz de vetar isso tornaria a regra apenas consultiva. Só sinais acima da severidade info podem disparar uma regra.

Ignorar uma família que o seu canal deixa ruidosa

Algumas famílias disparam em documentos genuínos por motivos que são do seu canal. Uma plataforma de assinatura eletrônica acrescenta uma revisão a cada contrato que assina, então incremental-updates diz pouco sobre esses arquivos. Liste essas famílias em ignore e elas saem da decisão: não disparam regra e não contam na nota que os cortes leem. Essa nota volta como effectiveRiskScore, calculada pela própria função de pontuação do motor sobre os sinais que sobraram, ao lado do riskScore inalterado. ignored lista as famílias ignoradas que de fato dispararam.

X-Tamperlens-Policy: {"ignore":["incremental-updates"]}

"policy": {
  "verdict": "accept",
  "riskScore": 80,              // a nota do motor, inalterada
  "effectiveRiskScore": 10,     // a nota sem incremental-updates
  "ignored": ["incremental-updates"],
  "thresholds": { "review": 30, "reject": 70 },
  "policyVersion": "pv1-2cb4ef611a9c"
}

ignore vence rejectOn e reviewOn para o mesmo id. O relatório em si não muda: o sinal ignorado continua em signals[], com nota e evidência, para quem for revisar o arquivo.

Presets

preset se expande em cortes antes de as suas chaves serem lidas, então qualquer chave que você mandar vence a do preset, e uma lista que você mandar substitui a do preset. Os valores vêm dos números da página de precisão medida. Só dois cortes têm números publicados, 30 e 70, porque é onde a nota salta: um sinal de severidade média põe o relatório em 30 ou mais, um de severidade alta em 70 ou mais. Por isso os presets mudam a ação de cada faixa, e nenhum rejeita abaixo de 70.

Preset review reject O que os números publicados dizem
strict 10 70 Rejeita o mesmo que o balanced e também manda para uma pessoa qualquer achado de severidade baixa. A taxa de falso positivo abaixo de 30 não foi medida. As seis falsificações que achatamos com o pdftoppm tiraram 10.
balanced 30 70 As faixas do motor, igual a não mandar cortes. Tirou 30 ou mais em 18,6% dos documentos genuínos do .gov americano e em 30,0% dos documentos genuínos da web brasileira; 70 ou mais em 7,4% e 10,0%. Nas falsificações que montamos, o corte de 30 pegou 100% das salvas como atualização incremental, 71,1% das reescritas pelo qpdf e 65,5% das que passaram de novo pelo Ghostscript.
lenient 70 100 Só a faixa alta chega a uma pessoa, o que em documentos genuínos é o 7,4% e 10,0% acima. Falsificações lavadas que tiram entre 30 e 69 são aceitas. Com que frequência um documento genuíno tira 100 não está publicado.

São pontos de partida, não uma garantia sobre os seus documentos. Os números foram tirados em duas populações e em falsificações que nós montamos (versões do motor e datas estão na página de precisão medida), e a taxa no seu tráfego depende do software que gerou os seus arquivos. Meça nos seus próprios documentos antes de confiar num preset.

Qual regra decidiu

Todo bloco policy traz policyVersion: pv1- seguido dos 12 primeiros caracteres hex de um sha256 da política normalizada. Normalizada quer dizer preset expandido, listas ordenadas e sem repetição, e chaves numa ordem fixa. A mesma regra mandada com chaves ou listas em outra ordem recebe o mesmo valor, e {"preset":"balanced"} recebe o mesmo valor que {}, porque as duas decidem igual. Guarde junto de cada decisão para saber qual regra a produziu.

  • Ids de sinal desconhecidos são aceitos em rejectOn, reviewOn e ignore, e simplesmente nunca casam. Renomear uma família não pode derrubar o seu pipeline.
  • Uma política malformada é 422 invalid_policy, nunca um retorno silencioso aos nossos padrões: um erro de digitação que deixasse você acreditando que uma regra está valendo quando não está é a única falha que isto não pode ter. Um nome de preset fora dos três acima também é malformado.
  • Omita e nada muda. A chave policy fica ausente da resposta, não nula, e o relatório é idêntico byte a byte ao que era antes deste recurso existir.

5. O relatório, anotado

Uma resposta 200 é um InspectionReport. O formato é um contrato estável; novas famílias de sinais são aditivas. Os campos de texto (title, detail, disclaimer) são gerados pelo motor em inglês: o verificador web traduz os títulos por família, mas a API devolve o texto original.

{
  // Identificador da chamada. O ÚNICO campo não determinístico da resposta.
  "id": "insp_2f1e9c04-7b3a-4d51-9f0e-6c2a18d4bb71",

  // Se isto mudar, os pesos do score podem ter mudado. Fixe nos seus testes.
  "engineVersion": "1.43.0",

  "summary": {
    "riskScore": 10,          // 0-100, agregação ponderada — não é probabilidade
    "riskBand": "low",        // "low" <30 · "elevated" 30-69 · "high" >=70
    "signalCount": 1,         // sinais com severidade acima de "info"
    "revisions": 1,           // 1 = nunca sofreu atualização incremental
    "reasonCode": "LOW_SEVERITY_ONLY" // motor 1.39.0: qual regra gerou a manchete (§5.3)
  },

  // Mais grave primeiro, depois em ordem alfabética por id. No máximo um por família.
  "signals": [
    {
      "id": "producer-fingerprint",     // uma das ids estáveis de família
      "severity": "low",                // "info" | "low" | "medium" | "high"
      "title": "PDF editing tool in the production chain (iLovePDF)",
      "detail": "The document's metadata names iLovePDF in its production chain. …",
      "message": {                      // motor 1.8.0 — o `detail` como código mais valores
        "code": "producer-fingerprint.rewritten",
        "params": { "tools": "iLovePDF" }
      },
      "evidence": {                     // formato varia por família; sempre legível por máquina
        "tools": ["iLovePDF"],
        "editorTools": ["iLovePDF"],
        "origin": "pdf-editor",         // um editor reescreve um PDF; ferramentas "authoring" o criam
        "producer": "iLovePDF",
        "creator": "iText 7.2.5",
        "revisions": 1,                 // revisão única, então "low" — "medium" exige re-salvamento
        "fullPageImagePages": []
      }
    },
    {
      "id": "id-inconsistency",
      "severity": "info",               // /ID divergente em arquivo de revisão única pontua zero
      "title": "Trailer /ID elements differ on a file written once",
      "detail": "The trailer /ID array holds two different identifiers. …",
      "evidence": {
        "idOriginal": "8f2c...a91b",
        "idCurrent": "41de...77c0",
        "trailerCount": 1,
        "revisions": 1
      }
    }
  ],

  // Fatos, não achados. Qualquer campo de texto pode vir null.
  "document": {
    "pages": 2,
    "producer": "iLovePDF",
    "creator": "iText 7.2.5",
    "creationDate": "2026-01-04T10:02:00.000Z",   // ISO-8601, ou null
    "modDate": "2026-01-06T18:41:00.000Z",
    "encrypted": false,
    "signed": false,
    "revisions": 1,     // escrito uma única vez; o /ID divergente sozinho não prova nada
    "sizeBytes": 184320,
    "sha256": "9c1e…40ab"  // motor 1.39.0: SHA-256 dos bytes que você enviou
  },

  // Motor 1.39.0. Pistas de agrupamento, só PDF; null em imagem, Office,
  // PDF criptografado e triagem. Nunca é achado sozinho (§5.3).
  "fingerprint": {
    "issuerProfileHash": "3f0a…c2d1",
    "baseFile": { "idPermanent": "8f2c...a91b", "creationDate": "2026-01-04T10:02:00.000Z",
                  "fontSubsetTags": ["KPLZWD", "QXRTMB"] }
  },

  // Motor 1.39.0. O que este relatório leu e o que não conseguiu ver (§5.3).
  "coverage": { "scope": "full", "blindSpots": ["clean-regeneration"] },

  "disclaimer": "Tamperlens reports risk signals, not authenticity verdicts. …"
}

Campos em que você realmente deve ramificar

  • summary.riskBand: a decisão grosseira de roteamento. O contrato das faixas é estreito de propósito: high é reservado para relatórios com pelo menos um sinal de severidade alta, então um acúmulo de achados fracos é limitado em 69 e nunca chega lá.
  • signals[].id e severity. Os ids de família são strings estáveis. Ramifique nelas quando quiser peso por sinal em vez de um único limiar, e você vai querer: se o seu fluxo envolve legitimamente clientes assinando documentos, você quer incremental-updates com peso menor e signature-coverage com peso maior.
  • signals[].evidence. Guarde. É pequeno, não contém conteúdo do documento e é exatamente o que um analista humano precisa. Trate o formato como específico por família e aditivo; não assuma que as chaves estão presentes.
  • signals[].message (motor 1.8.0). O mesmo parágrafo do detail, mas como um code estável mais os params interpolados nele. Use se você apresenta os achados em outro idioma, ou com as suas próprias palavras: os params são valores (deslocamentos, contagens, números de objeto), então a sua frase continua exatamente tão precisa quanto a nossa. O detail é renderizado a partir disso e não mudou, então nada que lê detail hoje precisa ser alterado. Um sinal também pode trazer notes[]: frases complementares anexadas depois da principal, no mesmo formato; o detail é a concatenação.
  • evidence.payloadIsUntrusted: presente e true em document-injection-markers e office-injection-markers, e desde o motor 1.30.0 também em office-hidden-content e office-tracked-changes, e desde o motor 1.42.0 em incremental-updates sempre que evidence.textChanges traz algum valor (§5.4). Significa que as strings em evidence.fields[].payload, evidence.fields[].name, evidence.recovered, evidence.authors e evidence.textChanges[].before/.after foram escritas por quem montou o documento, e nas famílias de injeção para serem lidas por um modelo de linguagem. Todo o resto daquele sinal (as categorias de indício, as contagens, que tipo de campo carregava, quais técnicas de ofuscação foram usadas) é nosso e é seguro. Não coloque a carga num prompt de modelo sem neutralizá-la. Nada escrito pelo autor do documento aparece em title, detail ou message.params: o texto nomeia o tipo de campo a partir de uma lista fixa, porque uma chave personalizada do Info também é uma string escolhida por ele. Veja §5.1.
  • document.encrypted, quando true, seis famílias de sinais dependentes de conteúdo não rodaram, e o relatório diz isso por meio de um sinal structure-warnings com evidence.code: "encrypted-content-not-analysed". A ausência de um sinal de conteúdo nesse relatório não significa nada. Detalhes.

Cada família, suas chaves de evidência, suas causas legítimas e as regras exatas de severidade: o guia de campo.

5.1 ?redact=payload

POST /api/v1/inspect?redact=payload devolve o mesmo relatório com toda string escrita pelo autor do documento suprimida: payload, name, recovered, authors e, desde o motor 1.42.0, before e after viram "[redacted]" e o sinal ganha evidence.payloadRedacted: true, para você distinguir “nada foi encontrado” de “isto não foi entregue a você”. Nada mais muda: a pontuação, a faixa, a severidade, as categorias de indício e todas as contagens são idênticas, e uma política de veredicto é avaliada sobre o relatório sem supressão, então seu veredicto não muda com esse parâmetro.

Existe para um formato de integração, e ele é comum: você entrega documentos a um modelo e quer o achado sem a frase que foi escrita para atacá-lo. Qualquer outro valor de redact é ignorado em vez de rejeitado. Há exatamente uma coisa a suprimir, e nada que um erro de digitação aqui pudesse desligar em silêncio.

O servidor MCP faz isso sempre, e esse é o único lugar em que nosso padrão seguro difere do desta API. O resultado de uma ferramenta MCP é o contexto de um modelo, então devolver a carga ali entregaria a injeção pela nossa própria ferramenta, com um rótulo confiável. O resumo do MCP traz categorias de indício e contagens; busque o relatório por esta API quando uma pessoa precisar ler as palavras.

curl -X POST 'https://tamperlens.com/api/v1/inspect?redact=payload' \
  -H 'Authorization: Bearer tl_...' \
  --data-binary @curriculo.pdf

5.2 receipt: provar depois o que devolvemos

Não guardamos o relatório (o histórico de uma conta guarda campos derivados, não o relatório), então não conseguimos reproduzir um para resolver uma disputa. Por isso toda resposta do /api/v1/inspect termina num receipt: uma assinatura HMAC sobre o corpo exato que devolvemos, com a versão do motor e a hora em que foi emitida. Ela cobre tudo o que vem acima dela na resposta, issuerBaseline, policy, retention e tenantMatches inclusive, e depois de qualquer ?redact=payload; nunca cobre a si mesma. Guarde o relatório e o recibo juntos. O recibo cobre o relatório, não o arquivo: prova o que dissemos, nunca o que o documento era.

"receipt": {
  "version": "v1",
  "issuedAt": "2026-08-15T19:32:25.147Z",
  "engineVersion": "1.43.0",
  "reportHash": "7543fa3f…76ef",
  "redacted": false,
  "signature": "…"
}

Para conferir o par depois, POST /api/v1/receipt/verify com o relatório exatamente como você o recebeu e o recibo ao lado. A chamada exige chave (um recibo é evidência sobre o relatório de um cliente, e o oráculo fica fora da internet anônima), tem rate limit e nunca conta na sua cota: ela não lê documento nenhum.

curl -X POST https://tamperlens.com/api/v1/receipt/verify \
  -H 'Authorization: Bearer tl_...' \
  -H 'Content-Type: application/json' \
  -d '{"report": {…o relatório, com ou sem a chave receipt…}, "receipt": {…}}'

# -> {"valid": true}
# -> {"valid": false, "reason": "bad_signature"}    nós nunca emitimos este recibo
# -> {"valid": false, "reason": "report_mismatch"}  recibo genuíno, relatório diferente

A assinatura é um HMAC, então só nós conseguimos verificá-la; uma assinatura de chave publicada, que qualquer um confere offline, é a v2 pretendida, e version está dentro dos bytes assinados para que um recibo v1 guardado continue verificando quando ela chegar. O argumento mais longo está em /pt/seguranca.

5.3 Regiões, sha256, fingerprint, coverage e reasonCode

O motor 1.39.0 acrescentou cinco fatos ao relatório. Nenhum deles é sinal e nenhum mexe em riskScore, riskBand ou signalCount: todo arquivo de teste tem exatamente a nota que tinha na 1.38.0. Todos ficam dentro do recibo, e o ?redact=payload não mexe neles porque nenhum carrega texto do documento.

  • evidence.regions, com regionsTotal e regionsTruncated ao lado, em cinco famílias: redaction-exposure (cada trecho de texto coberto, e o /Rect de cada marca de tarja nunca aplicada), glyph-anomalies (o valor fora da linha), running-balance-break (a linha impressa em cada elo quebrado), br-identifier-checksum (a linha impressa com cada identificador que falhou) e hybrid-page (a página inteira, como o seu /MediaBox). Nas duas famílias que também trazem findings[], cada região ganha findingIndex, o índice do achado que ela localiza. Cada região é {"page", "x0", "y0", "x1", "y1"}: página contada a partir de 1, coordenadas em pontos arredondadas a 0,01, no máximo 50 por sinal. O espaço é o espaço de usuário padrão do PDF, sem aplicar o /Rotate, e um MediaBox que não começa na origem mantém o deslocamento. É o espaço em que a página é desenhada. Para desenhar a caixa na tela, converta com o viewport da própria página (no pdf.js, page.getViewport() aplica a rotação e a origem do CropBox); não gire antes por conta própria. Uma região é um número de página e quatro números, nunca texto. O visualizador da seção 5.4 faz esse desenho por você.
  • document.sha256: o SHA-256 dos bytes recebidos, em hexadecimal minúsculo, em todo tipo de arquivo. Serve para casar um relatório guardado com o arquivo guardado.
  • fingerprint (só PDF; null em imagem, arquivo Office, PDF criptografado e relatório de triagem). issuerProfileHash é o SHA-256 do perfil estrutural de que um baseline de emissor é feito: produtor, criador, versão do PDF, estilo da tabela de referência cruzada, conjunto de fontes, tamanho de página, número de revisões e duas flags. Extratos genuínos de um banco em meses diferentes têm o mesmo, e é para isso que ele existe; hash igual nunca é evidência de cópia. baseFile traz as pistas de que dois arquivos foram salvos a partir do mesmo arquivo: idPermanent (o /ID[0] do trailer, em hex), creationDate e fontSubsetTags (os prefixos de seis letras das fontes subconjunto, ordenados, no máximo 20). As tags são uma pista fraca, que depende do gerador: muitos sorteiam a cada gravação e o pdfTeX deriva dos glifos usados. Geradores que numeram as tags ficam de fora: o Chrome grava AAAAAA, BAAAAA, CAAAAA e assim por diante, o mPDF MPDF... Só ficam tags com cara de sorteio, então a lista vem vazia num PDF impresso pelo Chrome. Junto com o idPermanent, funcionam como um pseudônimo do arquivo: identificam um arquivo, não uma pessoa, e não carregam dado pessoal.
  • coverage: {"scope", "blindSpots"}. scope é "triage" exatamente quando o relatório traz o bloco triage, "metadata-only" para imagem, PDF criptografado ou contêiner que não pôde ser percorrido, "partial" para PDF cuja análise foi interrompida (veja analysisIncomplete abaixo), e "full" nos outros casos. blindSpots é uma lista fechada, sempre nesta ordem: page-content-not-walked (triagem), pixels-not-decoded (imagem, ou PDF que desenha imagens), office-not-rendered (arquivo Office), encrypted-content, text-extraction-untrusted (extraction-anomalies disparou) e clean-regeneration. Este último está em todo relatório: um documento gerado inteiro pelo fraudador, com metadados e contas coerentes, não deixa nada para um motor estrutural achar (o resultado nulo que medimos).
  • summary.reasonCode: qual regra gerou a manchete. Vale a primeira regra que casar, nesta ordem:
    1. SIGNED_BYTES_CHANGED: signature-integrity em high.
    2. CONTENT_REPLACED_AFTER_CREATION: incremental-updates em high.
    3. REDACTION_EXPOSED: redaction-exposure em high.
    4. APPENDED_AFTER_SIGNATURE: signature-coverage achou bytes fora de um intervalo assinado que não são assinaturas posteriores (evidence.uncoveredExplained: false). Um contrato assinado por uma segunda parte não recebe este código.
    5. SIGNATURE_ONLY_REVISIONS: há pelo menos um achado em medium ou acima, e todo achado acima de info, low inclusive, é signature-coverage, incremental-updates ou id-inconsistency marcado como vindo de revisões posteriores que só acrescentam assinatura ou dados de validação de assinatura (evidence.exemptionReason: "signature-only-revision-capped-elevated"). É um contrato assinado por uma segunda parte e nada mais, relatado em elevated. Dá para rotear por este código sem ler a evidência. A regra falha fechada: qualquer outro achado acima de info, mesmo low, leva o relatório para as regras seguintes.
    6. SEVERAL_FAMILIES: duas ou mais famílias em medium ou acima.
    7. ONE_FAMILY: exatamente uma família em medium ou acima.
    8. LOW_SEVERITY_ONLY: há achados, todos low.
    9. NO_FINDINGS_PARTIAL_SCOPE: nada acima de info, e o relatório não leu tudo: coverage.scope não é "full", ou text-extraction-untrusted ou office-not-rendered é ponto cego, ou o PDF é digitalizado (toda página é uma imagem sem texto visível).
    10. NO_FINDINGS: nada acima de info.
    Os códigos descrevem o que foi observado, não o que alguém fez. NO_FINDINGS não afirma que o documento é autêntico.
  • analysisIncomplete (motor 1.43.0, só PDF): aparece só quando parte da análise não rodou. Relatório completo não tem essa chave.
    • {"reason": "work-limit", "limit": "..."}: um limite de trabalho, não de tempo, interrompeu a análise. limit diz qual foi o primeiro atingido, na ordem da leitura: input-bytes, inflated-bytes (64 MiB descomprimidos por documento), stream-inflated-bytes (32 MiB por stream), xref-entries, xref-free-entries, xref-chain, objects, decoded-scan-bytes, xmp-packet-bytes, analysed-pages (200), text-runs (100.000), page-content-bytes (8 MiB por página), page-content-operators (2.000.000 por página), content-bytes (24 MiB de conteúdo de página por documento, contando o conteúdo de Form XObject a cada vez que é desenhado), content-operators (8.000.000 por documento), text-operators (250.000 Tj, TJ, ' e " por documento), forms-per-page (400), form-content-bytes (1 MiB por form), form-invocations (2.000 por página), form-depth (8) ou graphics-state-depth (50). Um limite de trabalho para a análise no mesmo ponto para os mesmos bytes.
    • {"reason": "time-budget"}: o relógio de 10 segundos acabou antes. É rede de segurança: os limites de trabalho são calibrados para parar o documento bem antes no nosso servidor de produção, mas entrada adversarial, ou uma máquina mais lenta ou mais ocupada, ainda pode chegar nele. Um relatório sem time-budget não foi tocado pelo relógio; um com ele pode mudar se você repetir a chamada.
    • {"reason": "parse-error"}: a leitura estrutural falhou no meio.
    Em todos os casos coverage.scope é "partial", um relatório sem achados traz NO_FINDINGS_PARTIAL_SCOPE e o structure-warnings lista o mesmo corte entre os códigos de aviso. Um corte também pode mudar a nota de um documento real: uma leitura cortada não garante o que uma revisão de assinatura posterior tocou, então um contrato contra-assinado que bata num corte não recebe a isenção e sai high em vez de elevated. Até a 1.42.0 o relógio cortava um documento denso sem avisar, e o relatório podia mudar com a carga do servidor.

5.4 Incorporar o visualizador

O <tamperlens-viewer> desenha as evidence.regions de um relatório sobre o PDF de onde elas vieram: desenha as páginas com o pdf.js, leva cada região pelo viewport da própria página (então /Rotate e CropBox ficam certos) e contorna cada uma com a cor e o estilo de borda da severidade do sinal, com o título do sinal ao passar o mouse ou focar e uma lista dos achados embaixo de cada página. O verificador grátis da página inicial usa este componente.

O arquivo nunca sai do navegador. O visualizador lê o PDF da memória e só precisa do JSON do relatório que você já tem. Não usa chave de API e não faz nenhuma requisição levando o documento ou o relatório.

<input type="file" id="pdf" accept="application/pdf">
<tamperlens-viewer id="viewer"></tamperlens-viewer>
<script type="module">
  import "/viewer/tamperlens-viewer.js";
  // report: o JSON que o seu servidor recebeu do POST /api/v1/inspect
  // para este mesmo arquivo.
  document.getElementById("pdf").addEventListener("change", async (e) => {
    await document.getElementById("viewer").load(e.target.files[0], report);
  });
</script>

O load() aceita File, Blob, ArrayBuffer ou Uint8Array. Sirva o visualizador da sua própria origem, porque o navegador só inicia o worker do pdf.js a partir da origem da página: são os três arquivos em /viewer/ (tamperlens-viewer.js, geometry.js, tamperlens-viewer.css) ao lado do pdf.js 6.3.289 do pacote pdfjs-dist (Apache-2.0) em vendor/pdfjs/. Com uma CSP estrita, ele precisa de script-src 'self' e connect-src 'self', e não precisa de 'unsafe-inline', blob: nem 'wasm-unsafe-eval'. Use viewer.labels para traduzir os textos dele e viewer.signalTitle para dar aos sinais os nomes que você quiser.

5.4 textChanges: quais valores em dinheiro a última revisão trocou

Motor 1.42.0, só a pedido. Com ?changes=values no /inspect ou no /case, quando a última revisão de um PDF foi anexada a uma anterior, a evidência de incremental-updates compara a camada de texto dessa última revisão com a revisão imediatamente anterior e lista os valores em dinheiro que mudaram. Sem ele, que é o padrão, o laudo não traz nenhum dos campos abaixo; o verificador gratuito e o servidor MCP nunca pedem. Qualquer outro valor de changes é ignorado, e mode=triage nunca compara. Nada é renderizado, só a última revisão é comparada, e não mexe em severidade nem na nota.

"textChanges": [
  {
    "revision": 2, "page": 1, "kind": "replaced", "tokenKind": "money",
    "before": "4.683,15", "after": "6.983,15",
    "region": { "page": 1, "x0": 410.62, "y0": 464.14, "x1": 453.43, "y1": 477.89 }
  }
],
"textChangesTotal": 6,
"textChangesTruncated": false,
"payloadIsUntrusted": true
  • O que aparece: valores em dinheiro que mudaram, e nada mais. tokenKind é sempre money: valor com separador decimal e exatamente dois decimais (4.100,00, 1,234.56, 279,55), com ou sem R$, US$ ou $ na frente, grupos de milhar de exatamente três dígitos e no máximo doze dígitos inteiros. Cada before e after tem no máximo 40 caracteres e casa com esse padrão de ponta a ponta.
  • O que nunca aparece. Palavras, rótulos e nomes; o valor por extenso; datas (uma data de pagamento trocada ou uma data de nascimento); números que não são dinheiro (quantidades, percentuais, códigos); e qualquer identificador, nem mascarado: CPF, CNPJ, RG, PIS, CEP, agência e conta, dígitos de cartão, telefone, linha digitável, matrícula.
  • Valor que pode ser pedaço de identificador fica de fora. O texto é lido depois da normalização Unicode NFKC, sem espaços, pontos, traços, barras e caracteres invisíveis. Uma linha impressa cujo próprio texto tem uma sequência de nove dígitos ou mais, ou até onze grupos de dígitos que juntos passam no dígito verificador de CPF ou CNPJ, não é lida. Em volta de cada outra linha, a conferência lê pelo menos duas linhas acima e abaixo, e segue até juntar 13 dígitos de cada lado (linhas sem dígito e linhas só com valores completos não contam; no máximo 12 linhas para cada lado), emendadas sem separadores, e silencia a linha quando uma sequência ou grupo desses usa dígitos da própria linha. Isso pega número digitado com espaços, partido em trechos, colunas ou linhas, entremeado com linhas de enchimento ou com linhas de valores, ou escrito com dígitos de largura cheia ou de outras escritas. Valor que só está perto de um identificador completo aparece: não revela nada dele. Valor na mesma linha de um rótulo de identificador (CPF, CNPJ, RG, CNH, PIS, título, matrícula, conta, agência, cartão, telefone, CEP, nascimento, cuenta, DNI, NIE, NIF, RUT, CURP, IBAN, passaporte e afins, também escritos com pontos, como C.P.F.; um ponto entre duas palavras ou antes de dígitos, como em Doc.RG ou R.G.1.234,56, continua fechando o rótulo) sai, e também o valor nas duas linhas abaixo de um rótulo, a não ser que a linha do rótulo já traga o valor completo e conferível: um CEP de oito dígitos, um CPF ou CNPJ que passa no dígito verificador. Vale nas duas revisões, e uma linha silenciada numa fica silenciada nas duas. O preço é que alguns valores legítimos não aparecem: em 255 processos judiciais americanos ficam 89,6% dos valores.
  • Risco residual, e por que é só a pedido. Um identificador escrito no formato de dinheiro e sem rótulo reconhecível por perto (uma conta impressa como 12.345,67, ou um RG partido em duas metades com cara de valor) não se distingue de um valor, e pode aparecer como um. O mesmo vale para pedaços de um identificador separados por mais de 12 linhas. Quem guarda laudos ou os repassa a terceiros não deve mandar ?changes=values. Quem manda deve tratar before e after como conteúdo do documento; o ?redact=payload os suprime.
  • Continua sendo conteúdo do documento. Os valores foram escritos por quem montou o arquivo, então o sinal traz payloadIsUntrusted: true quando a lista não está vazia, e ?redact=payload e o servidor MCP trocam before e after por "[redacted]", mantendo kind, tokenKind e region.
  • kind. replaced: outro valor no mesmo lugar, ou o único valor que sobrou na mesma linha impressa. removed (after: null) e added (before: null): todo o resto. Um valor que só mudou de lugar, por exemplo abaixo de uma linha inserida, não é mudança.
  • region segue o contrato de região: onde o valor novo está pintado, ou, em removed, onde o valor antigo estava. Null quando a geometria não serviu.
  • No máximo 50 itens, do topo da página para baixo; textChangesTotal e textChangesTruncated dizem quando a lista é uma amostra.
  • textChangesSkipped quer dizer que a comparação foi recusada e textChanges vem vazio: linearized (arquivo linearizado ou com referência híbrida, em que a revisão anterior não tem um corte limpo), too-many-revisions (mais de 16) ou budget (a segunda leitura não caberia no que sobra dos orçamentos de trabalho da inspeção, ou uma página é densa demais para ser lida inteira; igual para os mesmos bytes) ou time-budget (o relógio acabou antes; o relatório também traz analysisIncomplete). Sem esses campos, nenhuma comparação foi tentada: arquivo criptografado, scope=triage ou cadeia de revisões sem um corte limpo.
  • Lista vazia é comum e normal. Uma revisão que só assina o documento ou só regrava metadados pinta o mesmo texto, então não aponta mudança. Um número trocado só nos pixels de uma imagem não está na camada de texto e não aparece aqui.

6. Erros

Todos os erros são JSON com uma string error. Nada sobre o documento vaza no corpo de um erro.

Status Corpo Quando
400 {"error":"no_file"} requisição multipart sem a parte file
400 {"error":"malformed_multipart","message":"The multipart body could not be parsed. Check the boundary and send the document as one file part."} não deu para ler o corpo multipart: boundary quebrado ou ausente, ou corpo cortado no meio de uma parte. Corrija a requisição; repetir os mesmos bytes dá a mesma resposta
400 {"error":"too_many_files","limit":10} /case com mais de 10 arquivos. Nada foi analisado nem cobrado
400 {"error":"not_enough_files","min":2} /case com menos de 2 arquivos. Para um documento só, use o /inspect
401 {"error":"invalid_api_key"} uma chave foi apresentada e não resolveu
403 {"error":"origin_not_allowed"} um navegador chamou a partir de uma origem que não é a nossa. Só navegador manda Origin, então um chamador no servidor nunca vê isso
413 {"error":"file_too_large","maxMb":10} corpo ou arquivo multipart acima do limite
413 {"error":"body_too_large","maxBytes":65536} o OUTRO 413: o limite pequeno de corpo que protege toda rota que não recebe arquivo. Você enviou algo que nunca foi um arquivo: mande o documento como upload. maxBytes é o limite da própria rota que recusou
413 {"error":"too_many_parts","message":"The multipart body has more fields, files or parts than an upload needs. Send the document as one file part with at most a few fields."} o corpo multipart tem mais campos, arquivos ou partes do que um upload precisa. Mande o documento como uma parte file só, com no máximo poucos campos
408 {"error":"body_timeout","timeoutSeconds":30} os arquivos de um /case não terminaram de chegar em 30 segundos; a conexão é fechada e nada é cobrado
413 {"error":"case_too_large","maxTotalMb":30} os arquivos de um /case somam mais de três vezes o limite de envio
422 {"error":"not_a_pdf"} os bytes não são um formato que a Tamperlens leia. O código é anterior ao suporte a Office e imagens e foi mantido para as integrações que dependem dele
422 {"error":"analysis_timeout","timeoutMs":15000} o documento esgotou o orçamento de CPU e a worker dele foi morta. Não faz sentido repetir: o problema é a entrada
422 {"error":"invalid_issuer"} o issuer enviado não é um slug (letras minúsculas, dígitos, hifens). Um slug desconhecido não é erro: você recebe o relatório normal, sem comparação com linha de base
422 {"error":"invalid_policy","message":"…"} não foi possível ler a política de veredicto, então nada foi decidido com ela. Recusada em vez de cair silenciosamente nos nossos padrões
422 {"error":"profile_rejected"} só no treino de linha de base. O perfil que o motor montou não passou nas nossas próprias verificações de privacidade e foi descartado em vez de guardado. Repetir não resolve; o motivo fica registrado do nosso lado
422 {"error":"no_usable_documents","reports":[…]} /case em que nenhum arquivo pôde ser analisado; cada item de reports traz o erro daquele arquivo. Nada foi cobrado
429 {"error":"quota_exceeded","quota":25,"used":25} chamador com chave acima da cota mensal e acima da margem gratuita que a segue nos planos pagos. A chamada recusada não é cobrada. Acompanhe o x-quota-state para ver isso chegando: ele diz within, depois soft-ceiling quando você entra na margem, e então exhausted
429 {"error":"rate_limited","retryAfterSeconds":60} chamadas rápidas demais: o limite anônimo por rede por hora, ou qualquer outro limitador do produto. Todos respondem com este código. Distinto de quota_exceeded de propósito: cota é o plano que você contratou, rate limit é o ritmo com que você chamou
503 {"error":"busy","retryAfterSeconds":5} todas as workers ocupadas e a fila cheia; Retry-After: 5 é enviado. Contrapressão honesta: repita
500 {"error":"internal_error"} culpa nossa. Genérico de propósito; o interno nunca vaza

Repita o 503 com backoff. Não repita o 422. No 429 quota_exceeded, enfileire em vez de descartar: o contador zera todo mês.

Cabeçalhos de cota. Toda resposta com chave carrega x-quota-limit, x-quota-used, x-quota-remaining, x-quota-ceiling e x-quota-state, em toda requisição, não só quando algo dá errado, então não há condicional para escrever. O x-quota-ceiling é quantos documentos são servidos de graça depois da cota antes de a API começar a recusar; é 0 no plano Grátis, onde os créditos pré-pagos fazem esse papel. Estão na lista de CORS, então código no navegador consegue lê-los entre origens.

Note o que não é erro: um arquivo que o parser não consegue entender. Entrada ilegível ou hostil degrada para um 200 carregando um sinal structure-warnings, em vez de lançar exceção. Um PDF quebrado é um achado, não uma falha.

7. Rate limits e cotas

  • Anônimo: 10 documentos por hora por IP do cliente. Sem cota, sem chave, relatório completo. Pensado para o verificador web e para experimentar a API.
  • Com chave: sem limite por hora; uma cota mensal de documentos por conta, definida pelo plano e dividida por todas as chaves dela. O uso é contado por análise bem-sucedida; uma chamada recusada por cota não conta.
  • Concorrência: as análises rodam num pool de worker threads com fila limitada e prazo rígido de 15 segundos por documento. Quando a fila enche, a API descarta carga com 503 busy e um header Retry-After, em vez de deixar a latência crescer sem limite.
  • Tamanho: 10 MB por documento.

Endpoints de sessão do painel. GET /api/v1/me e GET /api/v1/usage autenticam com o cookie de sessão do painel, não com um bearer token: uma requisição que leve apenas Authorization: Bearer … recebe 401 not_authenticated. Eles servem o painel; a superfície de API com chave é /inspect, /compare e /baselines.

Uso do mês corrente: GET /api/v1/me. Detalhamento por dia do mês atual: GET /api/v1/usage. Os dois também aparecem no painel.

Planos, cotas, pacotes de créditos e o que conta como um documento: a página de preços. Todo plano roda o mesmo motor: você paga por volume, nunca por detecção melhor.

8. Privacidade, retenção e determinismo

  • Sem conta, nenhum documento e nenhum histórico fica guardado. Os bytes enviados são lidos em memória e descartados quando a resposta é escrita. Nenhum conteúdo de documento é logado e não existe etapa de revisão humana. O que fica é uma linha no registro de uso: qual chave, quando, quantos bytes, qual faixa de risco.
  • Uma conta guarda um registro derivado curto de cada documento do /inspect e do /case enquanto o histórico dela está ligado (abaixo), e o arquivo em si só se ela ativou.
  • Nenhum terceiro no caminho da análise. Só CPU, sem modelos de ML, sem fornecedores externos de dados, sem chamadas de saída durante a análise.
  • Determinístico. Bytes idênticos produzem relatório idêntico, fora o id e os blocos que esta seção descreve, que ficam fora do relatório do motor. Fixe fixtures, faça snapshot dos relatórios e teste regressão contra nós. Isso é um uso previsto do plano gratuito, e se um bump de versão mudar um score, preferimos que você perceba antes de nós.
  • Registro de uso. As linhas de chamadas anônimas (que carregam um endereço IP) são apagadas depois de 90 dias; as com chave, depois de 400 dias.

Retenção: o que uma conta guarda

Uma chamada feita para uma conta (uma chave dela, ou a sessão do painel) com o histórico ligado guarda um registro por documento analisado: uma lista fechada de campos derivados (HMACs dos identificadores do arquivo sob um sal por conta, o hash do perfil do emissor, nota, faixa, código do motivo, ids e severidades dos sinais, veredito e versão da política, tipo de mídia, uma faixa de tamanho, versão do motor, datas). Nunca o arquivo, o nome dele, o texto, um payload ou um autor. A política de privacidade lista cada coluna. O histórico vem ligado por padrão em contas criadas depois que a retenção entrou no ar e desligado nas mais antigas até que elas liguem; os registros ficam 30, 90 ou 365 dias (90 por padrão).

Não guardar nada numa chamada ou numa chave. Mande X-Tamperlens-Retention: none, ou configure a chave com retention: none por um PATCH /api/v1/retention (keys: [{id, retention: "none"}]) feito com a sessão do painel; o painel não tem um botão por chave. O cabeçalho só desliga: qualquer valor diferente de inherit conta como none, então um erro de digitação não guarda nada. O verificador anônimo, uma chave sem conta, o /metadata, o /compare e o treino de baseline nunca guardam nada.

O arquivo em si só fica guardado quando a conta ligou a guarda de arquivos (7, 30 ou 90 dias, com e-mail confirmado): cifrado com AES-256-GCM sob uma chave só dele, para poder ser baixado ou analisado de novo com um motor mais novo.

O que uma chamada guardada devolve

Estes blocos ficam ao lado de policy, fora do relatório do motor e dentro do corpo que o recibo assina, então uma resposta colada de volta no /api/v1/receipt/verify continua válida. Cada um só aparece quando se aplica.

"retention": {
  "stored": true,
  "id": "insp_…",                 // o id do registro: o id do próprio relatório
  "expiresAt": "2027-01-11T10:00:00.000Z",
  "file": { "stored": true, "expiresAt": "2026-11-12T10:00:00.000Z" }
},
"tenantMatches": [
  { "inspectionId": "insp_…", "createdAt": "2026-09-30T14:02:11.000Z",
    "basis": ["id-permanent", "font-subset-tags"] }
]
  • retention: só aparece quando um registro foi guardado. file aparece quando a conta guarda arquivos: {stored: true, expiresAt}, ou {stored: false, reason} com reason busy (sem memória sobrando agora), full (o espaço reservado para arquivos guardados acabou) ou error. O registro derivado é guardado em todos os casos.
  • tenantMatches: até 20 registros anteriores da mesma conta (da mesma chave, para quem chama com chave) que têm a mesma origem deste arquivo, primeiro os de outros arquivos, do mais novo para o mais antigo. basis é same-file, id-permanent, font-subset-tags ou creation-instant, com as mesmas regras de especificidade do /case, aplicadas par a par. Nunca entre contas. Uma pista para revisar, nunca um veredito. Fica ausente quando nada casa, no primeiro registro da conta e sempre que nada foi guardado. Registros guardados por um motor mais antigo mantêm os identificadores na forma que aquele motor calculou. Como o recibo assina o bloco, compartilhar a resposta inteira compartilha esses ids e datas.
  • fileRetention: só no /case, no nível de cima, como {stored: false, reason: "busy"} quando os arquivos do caso não puderam ser guardados; cada relatório também diz isso no próprio retention.file.

Histórico, exclusão e rótulos

A sessão do painel vê e administra a conta inteira. Uma chave de API vê, apaga e rotula só os registros que ela mesma fez (uma chave rotacionada fica com os da anterior) e nunca muda a configuração da conta. Id de outra conta, de outra chave, vencido ou inexistente: todos respondem o mesmo 404.

RotaQuemO que faz
GET /api/v1/retentionsessão ou chavemode (off ou derived), days, bytesDays (7, 30, 90 ou null), bytesAvailable e as chaves vivas com a configuração de cada uma (uma chave só vê a si mesma)
PATCH /api/v1/retentionsó sessãoJSON {mode?, days?, bytesDays?, keys?: [{id, retention}]}, tudo ou nada. Encurtar um prazo vale para o que já está guardado; bytesDays: null apaga todos os arquivos guardados
GET /api/v1/history?cursor&limitsessão ou chaveDo mais novo para o mais antigo, limit até 100; devolva o nextCursor como cursor. Cada item é o registro derivado mais file, tenantMatchCount e feedback
GET /api/v1/history/:idsessão ou chaveUm registro com os rótulos e o tenantMatches dele
GET /api/v1/history/:id/filesessão ou chaveO arquivo guardado, decifrado e autenticado por inteiro antes do primeiro byte, com o nome <id>.<ext> (o nome original nunca foi guardado)
POST /api/v1/history/:id/reinspectsessão ou chaveAnalisa de novo o arquivo guardado com o motor atual; custa um documento, não guarda nada novo e acrescenta reinspection: {of, storedEngineVersion} dentro do recibo
DELETE /api/v1/history/:idsessão ou chaveApaga um registro com os rótulos e o arquivo dele
DELETE /api/v1/history?sha256=sessão ou chaveApaga todos os registros daquele arquivo; o servidor calcula o HMAC da conta
DELETE /api/v1/history?confirm=allsó sessãoApaga tudo o que a conta guardou e descarta o sal dela
POST /api/v1/feedbacksessão ou chaveJSON {inspectionId, signalId?, label}, label entre confirmed-fraud, genuine, false-positive e unsure. Sem texto livre; um rótulo novo para o mesmo alvo substitui o anterior

O que apagar faz. O registro some do banco na hora; restos nos arquivos do banco em disco somem na próxima varredura diária, no máximo, e os backups do banco saem em até 60 dias. Um arquivo guardado fica ilegível na hora, porque a chave dele vai junto com o registro, exceto nas imagens de disco inteiro do provedor até saírem da rotação (sete dias).

Erros destas rotas seguem o formato {error, message}: account_required e session_required (403), email_unverified (403, ao ligar a guarda de arquivos), inspection_not_found e file_not_retained (404), file_expired (410), retention_bytes_unavailable (409 no PATCH, 503 num download: este servidor não consegue guardar arquivos), no_live_key (409, uma sessão sem chave para cobrar a reanálise), file_unreadable (500, o arquivo falhou na autenticação), busy (503 com Retry-After, quando há arquivos guardados demais sendo servidos ou analisados ao mesmo tempo), invalid_api_key e not_authenticated (401), key_not_found (404, id de chave desconhecido no PATCH) e 400 para corpo ou query malformados (invalid_*, unknown_field, unknown_signal, confirm_required, ambiguous_delete). A reanálise também responde todo erro que o /inspect responde.

A versão longa, como os uploads são tratados, o que fica retido, a lista de subprocessadores: está na página de segurança, e o compromisso em si está na política de privacidade.

9. Nossa postura quanto ao disclaimer

Todo relatório carrega esta frase, e ela é a posição real do produto, não enfeite jurídico (o motor a devolve em inglês; a tradução vem depois):

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

A Tamperlens reporta sinais de risco, não veredictos de autenticidade. Sinais podem ter causas legítimas; combine-os com a sua própria lógica de decisão.

Concretamente, com o que isso nos compromete:

  • Nenhum veredicto NOSSO. Não existe "fraud": true, não existe booleano "authentic", e não vai existir. O relatório descreve o que é verdade sobre os bytes e para por aí.
    A política de veredicto não é exceção, e vale ser exato sobre o porquê: aquele bloco só aparece quando você manda uma regra, é calculado a partir de cortes que você escolheu, e não muda nenhuma nota, nenhuma faixa e nenhum sinal. É o seu julgamento devolvido com as entradas à mostra: o oposto de um fornecedor decidindo por você, e a razão de o código que o avalia estar deliberadamente fora do motor.
  • Causas legítimas são documentadas por sinal, no guia de campo, no mesmo nível de detalhe das maliciosas. Um PDF legitimamente salvo de novo vai disparar sinais. Isso não é bug; é o que "este arquivo foi modificado após a criação" significa.
  • Roteie, não recuse automaticamente. Mapeie faixas para filas de análise. Qualquer desenho que recuse uma pessoa por um único sinal estrutural vai recusar clientes reais que abriram o extrato no Preview.
  • Limites declarados. Sem OCR, sem parsing de transações, sem aritmética de saldo, sem checagem de identidade ou de titularidade da conta, sem validação criptográfica de assinatura e sem capacidade de detectar uma falsificação limpa gerada de uma vez a partir de um template falso. Um adversário determinado que conheça histórico de revisões consegue achatá-lo.

Próximos passos