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 →
401na 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 mesmodocument.sha256. Causa inocente: o mesmo extrato mandado duas vezes. -
same-base-file: arquivos diferentes compartilham a metade permanente do/IDdo trailer (evidence.basisincluiid-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/IDem todo arquivo que escreve, um gerador que numera as tags em vez de sorteá-las (o Chrome escreveBAAAAA,CAAAAAe 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,Keywordse 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/baselineslista os seus perfis e os incluídos, combuiltInseparando uns dos outros e um índice deconsistencypor dimensão. -
DELETE /api/v1/baselines/:issuerremove um dos seus. Um perfil incluído é compartilhado e responde404. - 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,reviewOneignore, 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 depresetfora dos três acima também é malformado. -
Omita e nada muda. A chave
policyfica 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[].ideseverity. 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ê querincremental-updatescom peso menor esignature-coveragecom 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 dodetail, mas como umcodeestável mais osparamsinterpolados 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. Odetailé renderizado a partir disso e não mudou, então nada que lêdetailhoje precisa ser alterado. Um sinal também pode trazernotes[]: frases complementares anexadas depois da principal, no mesmo formato; odetailé a concatenação. -
evidence.payloadIsUntrusted: presente etrueemdocument-injection-markerseoffice-injection-markers, e desde o motor 1.30.0 também emoffice-hidden-contenteoffice-tracked-changes, e desde o motor 1.42.0 emincremental-updatessempre queevidence.textChangestraz algum valor (§5.4). Significa que as strings emevidence.fields[].payload,evidence.fields[].name,evidence.recovered,evidence.authorseevidence.textChanges[].before/.afterforam 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 emtitle,detailoumessage.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 sinalstructure-warningscomevidence.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, comregionsTotaleregionsTruncatedao lado, em cinco famílias:redaction-exposure(cada trecho de texto coberto, e o/Rectde 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) ehybrid-page(a página inteira, como o seu/MediaBox). Nas duas famílias que também trazemfindings[], cada região ganhafindingIndex, 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;nullem 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.baseFiletraz as pistas de que dois arquivos foram salvos a partir do mesmo arquivo:idPermanent(o/ID[0]do trailer, em hex),creationDateefontSubsetTags(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 gravaAAAAAA,BAAAAA,CAAAAAe assim por diante, o mPDFMPDF... Só ficam tags com cara de sorteio, então a lista vem vazia num PDF impresso pelo Chrome. Junto com oidPermanent, 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 blocotriage,"metadata-only"para imagem, PDF criptografado ou contêiner que não pôde ser percorrido,"partial"para PDF cuja análise foi interrompida (vejaanalysisIncompleteabaixo), 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-anomaliesdisparou) eclean-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:SIGNED_BYTES_CHANGED:signature-integrityemhigh.CONTENT_REPLACED_AFTER_CREATION:incremental-updatesemhigh.REDACTION_EXPOSED:redaction-exposureemhigh.APPENDED_AFTER_SIGNATURE:signature-coverageachou 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.SIGNATURE_ONLY_REVISIONS: há pelo menos um achado emmediumou acima, e todo achado acima deinfo,lowinclusive, ésignature-coverage,incremental-updatesouid-inconsistencymarcado 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 emelevated. Dá para rotear por este código sem ler a evidência. A regra falha fechada: qualquer outro achado acima deinfo, mesmolow, leva o relatório para as regras seguintes.SEVERAL_FAMILIES: duas ou mais famílias emmediumou acima.ONE_FAMILY: exatamente uma família emmediumou acima.LOW_SEVERITY_ONLY: há achados, todoslow.NO_FINDINGS_PARTIAL_SCOPE: nada acima deinfo, e o relatório não leu tudo:coverage.scopenão é"full", outext-extraction-untrustedouoffice-not-renderedé ponto cego, ou o PDF é digitalizado (toda página é uma imagem sem texto visível).NO_FINDINGS: nada acima deinfo.
NO_FINDINGSnã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.limitdiz 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.000Tj,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) ougraphics-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 semtime-budgetnã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.
coverage.scopeé"partial", um relatório sem achados trazNO_FINDINGS_PARTIAL_SCOPEe ostructure-warningslista 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 saihighem vez deelevated. 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é sempremoney: valor com separador decimal e exatamente dois decimais (4.100,00,1,234.56,279,55), com ou semR$,US$ou$na frente, grupos de milhar de exatamente três dígitos e no máximo doze dígitos inteiros. Cadabeforeeaftertem 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 emDoc.RGouR.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 tratarbeforeeaftercomo conteúdo do documento; o?redact=payloados suprime. -
Continua sendo conteúdo do documento. Os valores foram
escritos por quem montou o arquivo, então o sinal traz
payloadIsUntrusted: truequando a lista não está vazia, e?redact=payloade o servidor MCP trocambeforeeafterpor"[redacted]", mantendokind,tokenKinderegion. -
kind.replaced: outro valor no mesmo lugar, ou o único valor que sobrou na mesma linha impressa.removed(after: null) eadded(before: null): todo o resto. Um valor que só mudou de lugar, por exemplo abaixo de uma linha inserida, não é mudança. -
regionsegue o contrato de região: onde o valor novo está pintado, ou, emremoved, onde o valor antigo estava. Null quando a geometria não serviu. -
No máximo 50 itens, do topo da página para baixo;
textChangesTotaletextChangesTruncateddizem quando a lista é uma amostra. -
textChangesSkippedquer dizer que a comparação foi recusada etextChangesvem 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) oubudget(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) outime-budget(o relógio acabou antes; o relatório também trazanalysisIncomplete). Sem esses campos, nenhuma comparação foi tentada: arquivo criptografado,scope=triageou 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 busye um headerRetry-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
/inspecte do/caseenquanto 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
ide 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.fileaparece quando a conta guarda arquivos:{stored: true, expiresAt}, ou{stored: false, reason}comreasonbusy(sem memória sobrando agora),full(o espaço reservado para arquivos guardados acabou) ouerror. 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-tagsoucreation-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óprioretention.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.
| Rota | Quem | O que faz |
|---|---|---|
GET /api/v1/retention | sessão ou chave | mode (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/retention | só sessão | JSON {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&limit | sessão ou chave | Do 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/:id | sessão ou chave | Um registro com os rótulos e o tenantMatches dele |
GET /api/v1/history/:id/file | sessão ou chave | O 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/reinspect | sessão ou chave | Analisa 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/:id | sessão ou chave | Apaga um registro com os rótulos e o arquivo dele |
DELETE /api/v1/history?sha256= | sessão ou chave | Apaga todos os registros daquele arquivo; o servidor calcula o HMAC da conta |
DELETE /api/v1/history?confirm=all | só sessão | Apaga tudo o que a conta guardou e descarta o sal dela |
POST /api/v1/feedback | sessão ou chave | JSON {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
- Referência OpenAPI Especificação legível por máquina e console de teste para cada endpoint.
- Guia de campo As vinte famílias de sinais, chaves de evidência, causas legítimas, lógica de severidade.
- Triagem de extratos bancários Como ligar os sinais a uma decisão de crédito ou de onboarding.
- Forense de metadados de PDF O que o motor realmente lê, estrutura por estrutura.