Guias de ponta a ponta
Oito receitas completas. Cada bloco de código roda como está, sem recorte e sem
pseudocódigo: você preenche a chave e o caminho do PDF, e ele funciona. Todos os exemplos
apontam para o sandbox; trocar para produção é remover o /sandbox da URL base e
usar uma chave adx_live_.
>>> PENDENTE DE BACKEND <<<
Estes exemplos seguem o contrato canônico docs/CONTRATO_API.openapi.yaml e
não foram executados contra um servidor real. Serão conferidos chamada por chamada quando
o serviço subir.
1. Enviar um documento para assinatura
São sempre dois passos: criar como rascunho e depois enviar. Não existe atalho de "criar e disparar", e a ausência dele é deliberada: disparar por efeito colateral de uma criação é como se envia contrato errado para cliente. Entre os dois passos você tem uma janela para validar.
// enviar.mjs: Node 18 ou mais novo, sem dependencia.
// ADM_CHAVE=... node enviar.mjs ./contrato.pdf
import { readFile } from "node:fs/promises";
const CHAVE = process.env.ADM_CHAVE;
const BASE = "https://api.adigitalmax.com.br/v1/sandbox";
/** Cliente minimo com tratamento de erro de verdade. */
async function api(caminho, opcoes = {}) {
const resposta = await fetch(`${BASE}${caminho}`, {
...opcoes,
headers: { Authorization: `Bearer ${CHAVE}`, Accept: "application/json", ...opcoes.headers },
});
if (resposta.status === 204) return null;
const corpo = await resposta.json();
if (!resposta.ok) {
const erro = new Error(`${corpo.codigo}: ${corpo.title}`);
erro.status = resposta.status;
erro.codigo = corpo.codigo; // ramifique por ele, nao pelo status
erro.requestId = corpo.requestId; // guarde: e o que o suporte pede
erro.erros = corpo.erros ?? [];
throw erro;
}
return corpo;
}
const DADOS = {
titulo: "Contrato de prestacao de servicos",
descricao: "Vigencia de 12 meses, renovacao automatica.",
nivelAssinatura: "SEGURA",
// Fatores exigidos de TODO signatario. Dois valores = dois fatores
// simultaneos; nao existe booleano de "exigir todos".
fatoresExigidos: ["EMAIL", "SMS_OTP"],
ordenado: true, // Carlos so recebe depois de Maria assinar
recusavel: true,
pararSeRecusado: true,
lembrete: "SEMANAL",
prazoEm: "2026-09-15T23:59:59-03:00",
expiraEm: "2026-09-30T23:59:59-03:00",
signatarios: [
{
nome: "Maria Oliveira",
email: "maria@exemplo.com.br",
telefone: "+5511988887777", // exigido pelo fator SMS_OTP
funcao: "ASSINAR",
metodoEntrega: "EMAIL",
ordem: 1,
},
{
nome: "Carlos Souza",
email: "carlos@empresa.com.br",
telefone: "+5511977776666",
funcao: "ASSINAR",
metodoEntrega: "EMAIL",
ordem: 2,
},
],
// Campos ficam no nivel do documento e apontam o dono por
// signatarioId, que so existe depois de criar. Por isso este exemplo
// posiciona os campos no segundo passo.
campos: [],
};
function conferir(documento, campos) {
const problemas = [];
for (const s of documento.signatarios) {
if (s.funcao === "ASSINAR") {
const tem = campos.some((c) => c.signatarioId === s.id && c.tipo === "ASSINATURA");
if (!tem) problemas.push(`${s.nome} nao tem campo de assinatura`);
}
const fatores = s.fatoresExigidos ?? documento.fatoresExigidos ?? [];
if (fatores.includes("SMS_OTP") && !s.telefone) {
problemas.push(`${s.nome} exige SMS_OTP e nao tem telefone`);
}
}
for (const c of campos) {
// O campo tem que caber na pagina, e o servidor recusa se nao couber.
if (c.x + c.largura > 1 || c.y + c.altura > 1) {
problemas.push(`campo na pagina ${c.pagina} nao cabe na pagina`);
}
}
return problemas;
}
async function main() {
const caminhoPdf = process.argv[2] || "./contrato.pdf";
const pdf = await readFile(caminhoPdf);
const forma = new FormData();
forma.append("dados", new Blob([JSON.stringify(DADOS)], { type: "application/json" }));
forma.append("arquivo", new Blob([pdf], { type: "application/pdf" }), "contrato.pdf");
// 1. Criar. Nasce em RASCUNHO; nada e disparado.
const rascunho = await api("/documentos", {
method: "POST",
body: forma,
headers: { "Idempotency-Key": "pedido-88231-criar" },
});
console.log("Rascunho:", rascunho.id, "|", rascunho.status);
// 2. Posicionar os campos, agora que os signatarios tem id.
const [maria, carlos] = rascunho.signatarios;
const campos = [
{ signatarioId: maria.id, tipo: "ASSINATURA", pagina: 7, x: 0.10, y: 0.72, largura: 0.34, altura: 0.07 },
{ signatarioId: maria.id, tipo: "DATA", pagina: 7, x: 0.10, y: 0.82, largura: 0.20, altura: 0.03 },
{ signatarioId: carlos.id, tipo: "ASSINATURA", pagina: 7, x: 0.56, y: 0.72, largura: 0.34, altura: 0.07 },
];
await api(`/documentos/${rascunho.id}/campos`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campos }),
});
// 3. Conferir antes de qualquer coisa sair.
const problemas = conferir(rascunho, campos);
if (problemas.length > 0) {
console.error("Nao vou enviar. Problemas encontrados:");
problemas.forEach((p) => console.error(" -", p));
// O rascunho fica la, editavel, e nao consumiu envelope.
process.exit(1);
}
// 4. Enviar. E aqui que um envelope e consumido.
const enviado = await api(`/documentos/${rascunho.id}/enviar`, {
method: "POST",
headers: { "Idempotency-Key": "pedido-88231-enviar" },
});
console.log("Enviado. Estado:", enviado.status);
for (const s of enviado.signatarios) {
console.log(` - ${s.nome}: ${s.status}${s.link ? " " + s.link : ""}`);
}
}
main().catch((e) => {
console.error(`Falhou (${e.status}) ${e.codigo}: ${e.message}`);
if (e.erros?.length) console.error("Campos:", e.erros);
console.error("requestId:", e.requestId);
process.exit(1);
});#!/usr/bin/env python3
"""Cria o rascunho, posiciona os campos, confere e so entao dispara.
pip install requests
ADM_CHAVE=... python3 enviar.py ./contrato.pdf
"""
import json
import os
import sys
import requests
CHAVE = os.environ["ADM_CHAVE"]
BASE = "https://api.adigitalmax.com.br/v1/sandbox"
class ErroApi(Exception):
def __init__(self, status, corpo):
self.status = status
self.codigo = corpo.get("codigo") # ramifique por ele
self.request_id = corpo.get("requestId")
self.erros = corpo.get("erros", [])
super().__init__(f"{self.codigo}: {corpo.get('title')}")
def api(metodo, caminho, **kwargs):
resposta = requests.request(
metodo,
f"{BASE}{caminho}",
headers={"Authorization": f"Bearer {CHAVE}", "Accept": "application/json",
**kwargs.pop("headers", {})},
timeout=60,
**kwargs,
)
if resposta.status_code == 204:
return None
corpo = resposta.json()
if not resposta.ok:
raise ErroApi(resposta.status_code, corpo)
return corpo
DADOS = {
"titulo": "Contrato de prestacao de servicos",
"descricao": "Vigencia de 12 meses, renovacao automatica.",
"nivelAssinatura": "SEGURA",
# Fatores exigidos de TODO signatario. Dois valores = dois fatores
# simultaneos; nao existe booleano de "exigir todos".
"fatoresExigidos": ["EMAIL", "SMS_OTP"],
"ordenado": True, # Carlos so recebe depois de Maria assinar
"recusavel": True,
"pararSeRecusado": True,
"lembrete": "SEMANAL",
"prazoEm": "2026-09-15T23:59:59-03:00",
"expiraEm": "2026-09-30T23:59:59-03:00",
"signatarios": [
{
"nome": "Maria Oliveira",
"email": "maria@exemplo.com.br",
"telefone": "+5511988887777", # exigido pelo fator SMS_OTP
"funcao": "ASSINAR",
"metodoEntrega": "EMAIL",
"ordem": 1,
},
{
"nome": "Carlos Souza",
"email": "carlos@empresa.com.br",
"telefone": "+5511977776666",
"funcao": "ASSINAR",
"metodoEntrega": "EMAIL",
"ordem": 2,
},
],
"campos": [],
}
def conferir(documento, campos):
problemas = []
for s in documento["signatarios"]:
if s["funcao"] == "ASSINAR":
tem = any(c.get("signatarioId") == s["id"] and c["tipo"] == "ASSINATURA"
for c in campos)
if not tem:
problemas.append(f"{s['nome']} nao tem campo de assinatura")
fatores = s.get("fatoresExigidos") or documento.get("fatoresExigidos") or []
if "SMS_OTP" in fatores and not s.get("telefone"):
problemas.append(f"{s['nome']} exige SMS_OTP e nao tem telefone")
for c in campos:
# O campo tem que caber na pagina, e o servidor recusa se nao couber.
if c["x"] + c["largura"] > 1 or c["y"] + c["altura"] > 1:
problemas.append(f"campo na pagina {c['pagina']} nao cabe na pagina")
return problemas
def main():
caminho_pdf = sys.argv[1] if len(sys.argv) > 1 else "./contrato.pdf"
# 1. Criar. Nasce em RASCUNHO; nada e disparado.
with open(caminho_pdf, "rb") as pdf:
rascunho = api(
"POST",
"/documentos",
files={
"dados": ("dados.json", json.dumps(DADOS), "application/json"),
"arquivo": ("contrato.pdf", pdf, "application/pdf"),
},
headers={"Idempotency-Key": "pedido-88231-criar"},
)
print("Rascunho:", rascunho["id"], "|", rascunho["status"])
# 2. Posicionar os campos, agora que os signatarios tem id.
maria, carlos = rascunho["signatarios"]
campos = [
{"signatarioId": maria["id"], "tipo": "ASSINATURA", "pagina": 7,
"x": 0.10, "y": 0.72, "largura": 0.34, "altura": 0.07},
{"signatarioId": maria["id"], "tipo": "DATA", "pagina": 7,
"x": 0.10, "y": 0.82, "largura": 0.20, "altura": 0.03},
{"signatarioId": carlos["id"], "tipo": "ASSINATURA", "pagina": 7,
"x": 0.56, "y": 0.72, "largura": 0.34, "altura": 0.07},
]
api("PUT", f"/documentos/{rascunho['id']}/campos", json={"campos": campos})
# 3. Conferir antes de qualquer coisa sair.
problemas = conferir(rascunho, campos)
if problemas:
print("Nao vou enviar. Problemas encontrados:", file=sys.stderr)
for p in problemas:
print(" -", p, file=sys.stderr)
# O rascunho fica la, editavel, e nao consumiu envelope.
sys.exit(1)
# 4. Enviar. E aqui que um envelope e consumido.
enviado = api("POST", f"/documentos/{rascunho['id']}/enviar",
headers={"Idempotency-Key": "pedido-88231-enviar"})
print("Enviado. Estado:", enviado["status"])
for s in enviado["signatarios"]:
print(f" - {s['nome']}: {s['status']} {s.get('link') or ''}")
if __name__ == "__main__":
try:
main()
except ErroApi as e:
print(f"Falhou ({e.status}) {e}", file=sys.stderr)
if e.erros:
print("Campos:", e.erros, file=sys.stderr)
print("requestId:", e.request_id, file=sys.stderr)
sys.exit(1)<?php
// enviar.php: PHP 8.1 ou mais novo, com curl.
// ADM_CHAVE=... php enviar.php ./contrato.pdf
declare(strict_types=1);
const BASE = 'https://api.adigitalmax.com.br/v1/sandbox';
final class ErroApi extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly ?string $codigo,
public readonly ?string $requestId,
public readonly array $erros,
string $mensagem
) {
parent::__construct($mensagem);
}
}
function api(string $metodo, string $caminho, array $opcoes = []): ?array
{
$ch = curl_init(BASE . $caminho);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $metodo,
CURLOPT_HTTPHEADER => array_merge(
['Authorization: Bearer ' . getenv('ADM_CHAVE'), 'Accept: application/json'],
$opcoes['headers'] ?? []
),
CURLOPT_TIMEOUT => 60,
]);
if (isset($opcoes['body'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $opcoes['body']);
}
$bruto = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 204) {
return null;
}
$corpo = json_decode((string) $bruto, true, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) {
throw new ErroApi(
$status,
$corpo['codigo'] ?? null, // ramifique por ele
$corpo['requestId'] ?? null,
$corpo['erros'] ?? [],
sprintf('%s: %s', $corpo['codigo'] ?? '?', $corpo['title'] ?? '?')
);
}
return $corpo;
}
$dados = [
'titulo' => 'Contrato de prestacao de servicos',
'descricao' => 'Vigencia de 12 meses, renovacao automatica.',
'nivelAssinatura' => 'SEGURA',
// Dois valores = dois fatores simultaneos.
'fatoresExigidos' => ['EMAIL', 'SMS_OTP'],
'ordenado' => true,
'recusavel' => true,
'pararSeRecusado' => true,
'lembrete' => 'SEMANAL',
'prazoEm' => '2026-09-15T23:59:59-03:00',
'expiraEm' => '2026-09-30T23:59:59-03:00',
'signatarios' => [
[
'nome' => 'Maria Oliveira', 'email' => 'maria@exemplo.com.br',
'telefone' => '+5511988887777', 'funcao' => 'ASSINAR',
'metodoEntrega' => 'EMAIL', 'ordem' => 1,
],
[
'nome' => 'Carlos Souza', 'email' => 'carlos@empresa.com.br',
'telefone' => '+5511977776666', 'funcao' => 'ASSINAR',
'metodoEntrega' => 'EMAIL', 'ordem' => 2,
],
],
'campos' => [],
];
try {
$caminhoPdf = $argv[1] ?? './contrato.pdf';
// 1. Criar. Nasce em RASCUNHO; nada e disparado.
$rascunho = api('POST', '/documentos', [
'headers' => ['Idempotency-Key: pedido-88231-criar'],
'body' => [
'dados' => new CURLStringFile(
json_encode($dados, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'dados.json',
'application/json'
),
'arquivo' => new CURLFile($caminhoPdf, 'application/pdf', 'contrato.pdf'),
],
]);
printf("Rascunho: %s | %s\n", $rascunho['id'], $rascunho['status']);
// 2. Posicionar os campos, agora que os signatarios tem id.
[$maria, $carlos] = $rascunho['signatarios'];
$campos = [
['signatarioId' => $maria['id'], 'tipo' => 'ASSINATURA', 'pagina' => 7,
'x' => 0.10, 'y' => 0.72, 'largura' => 0.34, 'altura' => 0.07],
['signatarioId' => $maria['id'], 'tipo' => 'DATA', 'pagina' => 7,
'x' => 0.10, 'y' => 0.82, 'largura' => 0.20, 'altura' => 0.03],
['signatarioId' => $carlos['id'], 'tipo' => 'ASSINATURA', 'pagina' => 7,
'x' => 0.56, 'y' => 0.72, 'largura' => 0.34, 'altura' => 0.07],
];
api('PUT', '/documentos/' . $rascunho['id'] . '/campos', [
'headers' => ['Content-Type: application/json'],
'body' => json_encode(['campos' => $campos], JSON_THROW_ON_ERROR),
]);
// 3. Conferir: todo signatario com funcao ASSINAR precisa de campo.
foreach ($rascunho['signatarios'] as $s) {
$tem = false;
foreach ($campos as $c) {
if ($c['signatarioId'] === $s['id'] && $c['tipo'] === 'ASSINATURA') {
$tem = true;
break;
}
}
if (!$tem) {
// O rascunho fica la, editavel, e nao consumiu envelope.
fwrite(STDERR, $s['nome'] . " nao tem campo de assinatura. Nao vou enviar.\n");
exit(1);
}
}
// 4. Enviar. E aqui que um envelope e consumido.
$enviado = api('POST', '/documentos/' . $rascunho['id'] . '/enviar', [
'headers' => ['Idempotency-Key: pedido-88231-enviar'],
]);
printf("Enviado. Estado: %s\n", $enviado['status']);
foreach ($enviado['signatarios'] as $s) {
printf(" - %s: %s %s\n", $s['nome'], $s['status'], $s['link'] ?? '');
}
} catch (ErroApi $e) {
fwrite(STDERR, sprintf("Falhou (%d) %s\n", $e->status, $e->getMessage()));
if ($e->erros !== []) {
fwrite(STDERR, 'Campos: ' . json_encode($e->erros) . "\n");
}
fwrite(STDERR, 'requestId: ' . ($e->requestId ?? '?') . "\n");
exit(1);
}#!/usr/bin/env bash
# enviar.sh: cria o rascunho, mostra o que foi entendido, pede
# confirmacao e dispara.
# ADM_CHAVE=... ./enviar.sh ./contrato.pdf
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?defina ADM_CHAVE}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
PDF="${1:-./contrato.pdf}"
PEDIDO="88231"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE")
DADOS=$(cat <<'JSON'
{
"titulo": "Contrato de prestacao de servicos",
"nivelAssinatura": "SEGURA",
"fatoresExigidos": ["EMAIL", "SMS_OTP"],
"ordenado": true,
"recusavel": true,
"pararSeRecusado": true,
"lembrete": "SEMANAL",
"prazoEm": "2026-09-15T23:59:59-03:00",
"expiraEm": "2026-09-30T23:59:59-03:00",
"signatarios": [
{ "nome": "Maria Oliveira", "email": "maria@exemplo.com.br",
"telefone": "+5511988887777", "funcao": "ASSINAR",
"metodoEntrega": "EMAIL", "ordem": 1 },
{ "nome": "Carlos Souza", "email": "carlos@empresa.com.br",
"telefone": "+5511977776666", "funcao": "ASSINAR",
"metodoEntrega": "EMAIL", "ordem": 2 }
],
"campos": []
}
JSON
)
echo "Criando rascunho..."
RASCUNHO=$(
curl --fail-with-body --silent --show-error "${AUTH[@]}" \
--request POST "$ADM_BASE/documentos" \
--header "Idempotency-Key: pedido-$PEDIDO-criar" \
--form "dados=$DADOS;type=application/json" \
--form "arquivo=@$PDF;type=application/pdf"
)
DOC_ID=$(echo "$RASCUNHO" | jq -r '.id')
MARIA=$(echo "$RASCUNHO" | jq -r '.signatarios[0].id')
CARLOS=$(echo "$RASCUNHO" | jq -r '.signatarios[1].id')
echo "Rascunho: $DOC_ID"
echo "Posicionando campos..."
curl --fail-with-body --silent --show-error "${AUTH[@]}" \
--request PUT "$ADM_BASE/documentos/$DOC_ID/campos" \
--header "Content-Type: application/json" \
--data "$(jq -nc --arg m "$MARIA" --arg c "$CARLOS" '{
campos: [
{ signatarioId: $m, tipo: "ASSINATURA", pagina: 7, x: 0.10, y: 0.72, largura: 0.34, altura: 0.07 },
{ signatarioId: $m, tipo: "DATA", pagina: 7, x: 0.10, y: 0.82, largura: 0.20, altura: 0.03 },
{ signatarioId: $c, tipo: "ASSINATURA", pagina: 7, x: 0.56, y: 0.72, largura: 0.34, altura: 0.07 }
]
}')" > /dev/null
echo "$RASCUNHO" | jq '{status, signatarios: [.signatarios[] | {nome, email, ordem}]}'
read -r -p "Disparar? (s/N) " RESPOSTA
[ "$RESPOSTA" = "s" ] || { echo "Cancelado. O rascunho continua editavel."; exit 0; }
curl --fail-with-body --silent --show-error "${AUTH[@]}" \
--request POST "$ADM_BASE/documentos/$DOC_ID/enviar" \
--header "Idempotency-Key: pedido-$PEDIDO-enviar" \
| jq '{status, signatarios: [.signatarios[] | {nome, status}]}'2. Posicionar os campos sem abrir o PDF
A coordenada é fracionária, de 0 a 1, com origem no canto superior esquerdo da página, que é
a convenção do DOM e a que o editor usa ao arrastar o campo sobre a prévia.
x: 0 é a margem esquerda, x: 1 é a direita; y: 0 é o
topo, y: 1 é o rodapé. Largura e altura são frações das dimensões da página.
Relativa e não absoluta em pontos porque o mesmo posicionamento precisa valer para A4, carta
e paisagem, e porque o PDF pode ser reescalado na renderização. Ponto fixo quebra em silêncio
quando o cliente sobe o mesmo contrato em outro tamanho de papel. O campo tem que caber:
x + largura <= 1 e y + altura <= 1, e violar isso devolve
ENTRADA_INVALIDA.
// A4 em pontos: 595.28 x 841.89. A origem do PDF fica embaixo a
// esquerda; a nossa fica em cima a esquerda, entao o y se inverte.
function paraFracao({ xPt, yPt, larguraPt, alturaPt }, larguraPagina = 595.28, alturaPagina = 841.89) {
return {
x: xPt / larguraPagina,
// A inversao do eixo Y e o erro mais comum desta conversao.
y: (alturaPagina - yPt - alturaPt) / alturaPagina,
largura: larguraPt / larguraPagina,
altura: alturaPt / alturaPagina,
};
}
// Um retangulo de assinatura de 200x40 pt, a 60 pt da esquerda e
// 120 pt do rodape:
console.log(paraFracao({ xPt: 60, yPt: 120, larguraPt: 200, alturaPt: 40 }));
// { x: 0.1008, y: 0.8100, largura: 0.3360, altura: 0.0475 }
| Onde | x | y | largura | altura |
|---|---|---|---|---|
| Assinatura, coluna esquerda | 0.10 | 0.72 | 0.34 | 0.07 |
| Assinatura, coluna direita | 0.56 | 0.72 | 0.34 | 0.07 |
| Assinatura, centralizada | 0.33 | 0.75 | 0.34 | 0.07 |
| Data, abaixo da assinatura | 0.10 | 0.82 | 0.20 | 0.03 |
| Rubrica, rodapé direito | 0.82 | 0.93 | 0.12 | 0.04 |
Para rubricar todas as páginas, repita o campo RUBRICA variando só
pagina. Não existe atalho de "todas as páginas" no contrato, e isso é
intencional: rubrica é ato por página, e a trilha registra cada uma. O campo
z resolve sobreposição, quando dois campos disputam a mesma área.
3. Acompanhar até a conclusão
Use webhook. Sondar a API de minuto em minuto para descobrir que nada mudou queima a sua cota de 60 requisições por minuto e não deixa o documento ser assinado mais rápido. O código do receptor, com validação de assinatura, está em Webhooks.
Sondagem tem um lugar legítimo: conciliação. Uma vez por hora, ou uma vez por dia, você pergunta à API o estado do que ainda está aberto no seu banco e corrige divergências causadas por webhook perdido. É isto que o exemplo abaixo faz, e ele é o complemento do webhook, não o substituto.
#!/usr/bin/env python3
"""Conciliacao horaria: percorre o que ainda esta aberto e reporta.
Roda em cron, uma vez por hora. Nao substitui webhook: existe para
achar o que o webhook perdeu.
pip install requests
ADM_CHAVE=... python3 conciliar.py
"""
import os
import sys
import time
import requests
CHAVE = os.environ["ADM_CHAVE"]
BASE = "https://api.adigitalmax.com.br/v1/sandbox"
sessao = requests.Session()
sessao.headers.update({"Authorization": f"Bearer {CHAVE}", "Accept": "application/json"})
def paginar(caminho: str, **parametros):
"""Percorre todas as paginas de uma listagem por cursor."""
cursor = None
while True:
if cursor:
parametros["cursor"] = cursor
resposta = sessao.get(f"{BASE}{caminho}", params=parametros, timeout=30)
# 429: respeite o Retry-After em vez de tentar de novo na hora.
if resposta.status_code == 429:
espera = int(resposta.headers.get("Retry-After", "5"))
print(f"Limite atingido; aguardando {espera}s", file=sys.stderr)
time.sleep(espera)
continue
resposta.raise_for_status()
corpo = resposta.json()
yield from corpo["itens"]
# Pare quando proximoCursor vier nulo ou ausente, e nunca porque
# "a pagina veio curta": sao coisas diferentes.
cursor = corpo.get("proximoCursor")
if not cursor:
return
def main() -> None:
abertos = 0
for status in ("AGUARDANDO_ASSINATURAS", "PARCIALMENTE_ASSINADO"):
for resumo in paginar("/documentos", status=status, limite=100):
abertos += 1
detalhe = sessao.get(f"{BASE}/documentos/{resumo['id']}", timeout=30).json()
assinaram = sum(1 for s in detalhe["signatarios"] if s["status"] == "ASSINADO")
total = len(detalhe["signatarios"])
# Quem falhou na entrega e o unico caso realmente acionavel:
# quase sempre e endereco errado, e tem conserto.
falhas = [s for s in detalhe["signatarios"] if s["status"] == "FALHA_ENTREGA"]
print(f"{detalhe['id']} {assinaram}/{total} {detalhe['status']}")
for s in falhas:
print(f" !! entrega falhou para {s['nome']} <{s.get('email')}>")
# PATCH em /documentos/{id}/signatarios/{id} corrige o
# e-mail, e POST em /reenviar dispara de novo.
if detalhe.get("prazoEm"):
print(f" prazo: {detalhe['prazoEm']}")
print(f"\n{abertos} documentos abertos.")
if __name__ == "__main__":
main()// conciliar.mjs: percorre os documentos abertos respeitando o limite.
// ADM_CHAVE=... node conciliar.mjs
const CHAVE = process.env.ADM_CHAVE;
const BASE = "https://api.adigitalmax.com.br/v1/sandbox";
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
const cabecalhos = { Authorization: `Bearer ${CHAVE}`, Accept: "application/json" };
/** Gerador que percorre todas as paginas de uma listagem por cursor. */
async function* paginar(caminho, parametros = {}) {
let cursor = null;
for (;;) {
const query = new URLSearchParams({ ...parametros, ...(cursor ? { cursor } : {}) });
const resposta = await fetch(`${BASE}${caminho}?${query}`, { headers: cabecalhos });
// 429: o Retry-After diz quanto esperar. Obedeca em vez de insistir.
if (resposta.status === 429) {
const espera = Number(resposta.headers.get("Retry-After") || 5);
console.error(`Limite atingido; aguardando ${espera}s`);
await dormir(espera * 1000);
continue;
}
const corpo = await resposta.json();
if (!resposta.ok) {
throw new Error(`${corpo.codigo}: ${corpo.title} (${corpo.requestId})`);
}
for (const item of corpo.itens) yield item;
// Pare quando proximoCursor vier nulo, nunca por pagina curta.
cursor = corpo.proximoCursor;
if (!cursor) return;
}
}
let abertos = 0;
for (const status of ["AGUARDANDO_ASSINATURAS", "PARCIALMENTE_ASSINADO"]) {
for await (const resumo of paginar("/documentos", { status, limite: "100" })) {
abertos += 1;
const detalhe = await fetch(`${BASE}/documentos/${resumo.id}`, { headers: cabecalhos })
.then((r) => r.json());
const assinaram = detalhe.signatarios.filter((s) => s.status === "ASSINADO").length;
const falhas = detalhe.signatarios.filter((s) => s.status === "FALHA_ENTREGA");
console.log(`${detalhe.id} ${assinaram}/${detalhe.signatarios.length} ${detalhe.status}`);
for (const s of falhas) {
console.log(` !! entrega falhou para ${s.nome} <${s.email}>`);
// PATCH no signatario corrige o e-mail; POST /reenviar dispara de novo.
}
}
}
console.log(`\n${abertos} documentos abertos.`);#!/usr/bin/env bash
# conciliar.sh: percorre todas as paginas de documentos abertos.
# ADM_CHAVE=... ./conciliar.sh
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?defina ADM_CHAVE}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE" --header "Accept: application/json")
TOTAL=0
for STATUS in AGUARDANDO_ASSINATURAS PARCIALMENTE_ASSINADO; do
CURSOR=""
while :; do
URL="$ADM_BASE/documentos?status=$STATUS&limite=100"
[ -n "$CURSOR" ] && URL="$URL&cursor=$CURSOR"
PAGINA=$(curl --fail-with-body --silent --show-error "${AUTH[@]}" "$URL")
echo "$PAGINA" | jq -r '.itens[] | "\(.id) \(.status)"'
TOTAL=$(( TOTAL + $(echo "$PAGINA" | jq '.itens | length') ))
# Pare quando proximoCursor vier nulo, e nao por pagina curta.
CURSOR=$(echo "$PAGINA" | jq -r '.proximoCursor // empty')
[ -z "$CURSOR" ] && break
done
done
echo
echo "$TOTAL documentos abertos."4. Baixar o PDF assinado
O arquivo assinado passa a existir quando o documento chega a ASSINADO, ou seja,
quando o evento document.completed chega. É uma rota só, com o tipo em parâmetro
de consulta: GET /documentos/{documentoId}/arquivo?tipo=ASSINADO.
Do outro lado, a API resolve o objeto dentro do escopo da organização, verifica o direito e
só então delega a entrega ao servidor web por X-Accel-Redirect. Não existe
caminho público previsível para o arquivo, e o diretório nunca é servido como estático.
#!/usr/bin/env bash
# Baixa o PDF assinado e confere o hash contra o certificado.
# ADM_CHAVE=... ./baixar.sh 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?defina ADM_CHAVE}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
DOC_ID="${1:?informe o id do documento}"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE")
# --location segue eventual redirecionamento interno e grava o PDF.
curl --location --fail-with-body --silent --show-error "${AUTH[@]}" \
--output "$DOC_ID-assinado.pdf" \
--write-out "HTTP %{http_code}, %{size_download} bytes\n" \
"$ADM_BASE/documentos/$DOC_ID/arquivo?tipo=ASSINADO"
# O certificado declara o hash do documento assinado. Se divergir, o
# arquivo corrompeu no caminho e nao deve ser arquivado como prova.
ESPERADO=$(
curl --fail-with-body --silent "${AUTH[@]}" \
--header "Accept: application/json" \
"$ADM_BASE/documentos/$DOC_ID/certificado" | jq -r '.hashDocumento'
)
OBTIDO=$(sha256sum "$DOC_ID-assinado.pdf" | cut -d' ' -f1)
echo "esperado: $ESPERADO"
echo "obtido: $OBTIDO"
[ "$ESPERADO" = "$OBTIDO" ] && echo "OK" || { echo "HASH DIVERGENTE"; exit 1; }// baixar.mjs: baixa o PDF assinado e confere o hash.
// ADM_CHAVE=... node baixar.mjs 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
import { writeFile } from "node:fs/promises";
import crypto from "node:crypto";
const CHAVE = process.env.ADM_CHAVE;
const BASE = "https://api.adigitalmax.com.br/v1/sandbox";
const DOC_ID = process.argv[2];
const cabecalhos = { Authorization: `Bearer ${CHAVE}` };
// 1. O certificado declara o hash esperado. Ele so existe depois de o
// documento chegar a ASSINADO; antes disso vem CERTIFICADO_INDISPONIVEL.
const certificado = await fetch(`${BASE}/documentos/${DOC_ID}/certificado`, {
headers: { ...cabecalhos, Accept: "application/json" },
}).then((r) => r.json());
if (certificado.codigo === "CERTIFICADO_INDISPONIVEL") {
console.error("O documento ainda nao foi concluido.");
process.exit(1);
}
// 2. Baixar o arquivo. O tipo vai em parametro de consulta.
const resposta = await fetch(`${BASE}/documentos/${DOC_ID}/arquivo?tipo=ASSINADO`, {
headers: cabecalhos,
});
if (!resposta.ok) {
const erro = await resposta.json();
console.error(resposta.status, erro.codigo, erro.requestId);
process.exit(1);
}
const pdf = Buffer.from(await resposta.arrayBuffer());
// 3. Confira o hash antes de arquivar. Arquivo corrompido no caminho
// nao serve como prova, e o erro so aparece anos depois.
const obtido = crypto.createHash("sha256").update(pdf).digest("hex");
if (obtido !== certificado.hashDocumento) {
console.error("HASH DIVERGENTE. Nao arquive este arquivo.");
console.error("esperado:", certificado.hashDocumento);
console.error("obtido: ", obtido);
process.exit(1);
}
await writeFile(`${DOC_ID}-assinado.pdf`, pdf);
console.log(`Gravado (${pdf.length} bytes), hash conferido.`);
console.log("codigo de validacao:", certificado.qrConteudo);#!/usr/bin/env python3
"""Baixa o PDF assinado e confere o hash contra o certificado.
pip install requests
ADM_CHAVE=... python3 baixar.py 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
"""
import hashlib
import os
import sys
import requests
CHAVE = os.environ["ADM_CHAVE"]
BASE = "https://api.adigitalmax.com.br/v1/sandbox"
DOC_ID = sys.argv[1]
cabecalhos = {"Authorization": f"Bearer {CHAVE}"}
# 1. O certificado declara o hash esperado. So existe depois de ASSINADO.
certificado = requests.get(
f"{BASE}/documentos/{DOC_ID}/certificado",
headers={**cabecalhos, "Accept": "application/json"},
timeout=60,
).json()
if certificado.get("codigo") == "CERTIFICADO_INDISPONIVEL":
sys.exit("O documento ainda nao foi concluido.")
# 2. Baixar o arquivo. O tipo vai em parametro de consulta.
resposta = requests.get(
f"{BASE}/documentos/{DOC_ID}/arquivo",
params={"tipo": "ASSINADO"},
headers=cabecalhos,
timeout=120,
)
resposta.raise_for_status()
pdf = resposta.content
# 3. Confira o hash antes de arquivar.
obtido = hashlib.sha256(pdf).hexdigest()
if obtido != certificado["hashDocumento"]:
print("HASH DIVERGENTE. Nao arquive este arquivo.", file=sys.stderr)
print("esperado:", certificado["hashDocumento"], file=sys.stderr)
print("obtido: ", obtido, file=sys.stderr)
sys.exit(1)
with open(f"{DOC_ID}-assinado.pdf", "wb") as saida:
saida.write(pdf)
print(f"Gravado ({len(pdf)} bytes), hash conferido.")
print("codigo de validacao:", certificado.get("qrConteudo"))<?php
// baixar.php: baixa o PDF assinado e confere o hash.
// ADM_CHAVE=... php baixar.php 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
declare(strict_types=1);
const BASE = 'https://api.adigitalmax.com.br/v1/sandbox';
$chave = (string) getenv('ADM_CHAVE');
$docId = $argv[1] ?? exit("Informe o id do documento.\n");
function pegar(string $url, string $chave, array $extra = []): string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => array_merge(['Authorization: Bearer ' . $chave], $extra),
CURLOPT_TIMEOUT => 120,
]);
$bruto = curl_exec($ch);
curl_close($ch);
return (string) $bruto;
}
// 1. O certificado declara o hash esperado. So existe depois de ASSINADO.
$certificado = json_decode(
pegar(BASE . "/documentos/$docId/certificado", $chave, ['Accept: application/json']),
true,
512,
JSON_THROW_ON_ERROR
);
if (($certificado['codigo'] ?? null) === 'CERTIFICADO_INDISPONIVEL') {
exit("O documento ainda nao foi concluido.\n");
}
// 2. Baixar o arquivo. O tipo vai em parametro de consulta.
$pdf = pegar(BASE . "/documentos/$docId/arquivo?tipo=ASSINADO", $chave);
// 3. Confira o hash antes de arquivar.
$obtido = hash('sha256', $pdf);
if (!hash_equals($certificado['hashDocumento'], $obtido)) {
fwrite(STDERR, "HASH DIVERGENTE. Nao arquive este arquivo.\n");
fwrite(STDERR, 'esperado: ' . $certificado['hashDocumento'] . "\n");
fwrite(STDERR, 'obtido: ' . $obtido . "\n");
exit(1);
}
file_put_contents("$docId-assinado.pdf", $pdf);
printf("Gravado (%d bytes), hash conferido.\n", strlen($pdf));
printf("codigo de validacao: %s\n", $certificado['qrConteudo'] ?? '-');Guarde uma cópia do PDF assinado e do certificado no seu próprio armazenamento. Nós guardamos, mas a política de retenção da sua organização pode expurgar, o seu contrato conosco pode terminar, e a prova de um contrato de doze anos precisa sobreviver ao fornecedor que a gerou. Guarde também o código de validação: com ele, a autenticidade continua conferível pela validação pública.
5. Certificado de conclusão e evidências
O certificado é o que se apresenta quando a assinatura é questionada. Ele reúne, em um pacote congelado, o hash do arquivo, a lista de signatários com os fatores que cada um cumpriu, IP, horário e dados técnicos de cada ato, e a sequência integral de eventos com o hash de cada elo.
Ele é versionado e encadeado: emitir de novo cria uma versão nova ligada
por hashAnterior à anterior, e não sobrescreve. Isso é o que permite provar que
a prova não foi trocada.
#!/usr/bin/env bash
# Guarda o dossie completo de um documento concluido: PDF assinado,
# certificado em PDF, certificado em JSON e a trilha inteira.
# ADM_CHAVE=... ./dossie.sh 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?defina ADM_CHAVE}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
DOC_ID="${1:?informe o id do documento}"
DESTINO="dossie-$DOC_ID"
mkdir -p "$DESTINO"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE")
# 1. O PDF assinado.
curl --location --fail-with-body --silent "${AUTH[@]}" \
--output "$DESTINO/assinado.pdf" \
"$ADM_BASE/documentos/$DOC_ID/arquivo?tipo=ASSINADO"
# 2. O certificado em PDF, para anexar a um processo.
curl --location --fail-with-body --silent "${AUTH[@]}" \
--output "$DESTINO/certificado.pdf" \
"$ADM_BASE/documentos/$DOC_ID/arquivo?tipo=CERTIFICADO"
# 3. O mesmo certificado em JSON, para o seu sistema indexar.
curl --fail-with-body --silent "${AUTH[@]}" \
--header "Accept: application/json" \
"$ADM_BASE/documentos/$DOC_ID/certificado" > "$DESTINO/certificado.json"
# 4. A trilha completa, mais detalhada que o resumo do certificado.
curl --fail-with-body --silent "${AUTH[@]}" \
--header "Accept: application/json" \
"$ADM_BASE/documentos/$DOC_ID/auditoria?limite=100" > "$DESTINO/auditoria.json"
echo "Dossie em $DESTINO/"
jq -r '"codigo: \(.codigo)\nversao: \(.versao)\nhash do documento: \(.hashDocumento)"' \
"$DESTINO/certificado.json"#!/usr/bin/env python3
"""Guarda o dossie completo de um documento concluido.
pip install requests
ADM_CHAVE=... python3 dossie.py 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
"""
import json
import os
import pathlib
import sys
import requests
CHAVE = os.environ["ADM_CHAVE"]
BASE = "https://api.adigitalmax.com.br/v1/sandbox"
DOC_ID = sys.argv[1]
destino = pathlib.Path(f"dossie-{DOC_ID}")
destino.mkdir(exist_ok=True)
sessao = requests.Session()
sessao.headers.update({"Authorization": f"Bearer {CHAVE}"})
# 1. O PDF assinado e 2. o certificado em PDF, pela mesma rota.
for tipo, nome in (("ASSINADO", "assinado.pdf"), ("CERTIFICADO", "certificado.pdf")):
resposta = sessao.get(
f"{BASE}/documentos/{DOC_ID}/arquivo", params={"tipo": tipo}, timeout=120
)
resposta.raise_for_status()
(destino / nome).write_bytes(resposta.content)
# 3. O certificado em JSON, para o seu sistema indexar.
certificado = sessao.get(
f"{BASE}/documentos/{DOC_ID}/certificado",
headers={"Accept": "application/json"},
timeout=60,
).json()
(destino / "certificado.json").write_text(
json.dumps(certificado, indent=2, ensure_ascii=False), encoding="utf-8"
)
# 4. A trilha completa, mais detalhada que o resumo do certificado.
auditoria = sessao.get(
f"{BASE}/documentos/{DOC_ID}/auditoria", params={"limite": 100}, timeout=60
).json()
(destino / "auditoria.json").write_text(
json.dumps(auditoria, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Dossie em {destino}/")
print("codigo:", certificado["codigo"])
print("versao:", certificado["versao"])
print("hash do documento:", certificado["hashDocumento"])
for evento in auditoria["itens"]:
print(f" {evento['ocorridoEm']} {evento['acao']} por {evento['ator']} de {evento.get('ip')}")Vale entender o que cada peça sustenta. O hash prova que o arquivo não mudou desde a assinatura. Os fatores cumpridos sustentam a autoria: é a diferença entre "alguém com acesso a este e-mail clicou" e "alguém com acesso a este e-mail e a este telefone clicou". A trilha encadeada sustenta a não-repudiação, mostrando que o signatário viu o documento antes de assinar e quanto tempo passou entre uma coisa e outra.
6. Criar e usar modelos
Se você envia o mesmo contrato com nomes diferentes, modelo é a diferença entre subir um PDF de 2 MB e posicionar campos a cada envio, e mandar um JSON de vinte linhas. Em volume, é a diferença entre uma integração que funciona e uma que estoura o limite de taxa.
#!/usr/bin/env bash
# O PDF tem marcadores {{cliente_nome}} e {{valor_mensal}} no texto,
# que serao substituidos na hora de instanciar.
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
MODELO='{
"nome": "Contrato de prestacao de servicos - padrao 2026",
"descricao": "Modelo aprovado pelo juridico em 2026-07-30. Nao editar sem revisao.",
"escopo": "ORGANIZACAO",
"nivelAssinatura": "SEGURA",
"fatoresExigidos": ["EMAIL", "SMS_OTP"],
"papeis": [
{
"rotulo": "Contratante",
"funcao": "ASSINAR",
"ordem": 1,
"campos": [
{ "tipo": "ASSINATURA", "pagina": 7, "x": 0.10, "y": 0.72, "largura": 0.34, "altura": 0.07 },
{ "tipo": "DATA", "pagina": 7, "x": 0.10, "y": 0.82, "largura": 0.20, "altura": 0.03 }
]
},
{
"rotulo": "Contratada",
"funcao": "ASSINAR",
"ordem": 2,
"campos": [
{ "tipo": "ASSINATURA", "pagina": 7, "x": 0.56, "y": 0.72, "largura": 0.34, "altura": 0.07 }
]
}
]
}'
curl --fail-with-body --silent --show-error \
--request POST "$ADM_BASE/modelos" \
--header "Authorization: Bearer $ADM_CHAVE" \
--form "dados=$MODELO;type=application/json" \
--form "arquivo=@./contrato-padrao.pdf;type=application/pdf" \
| jq '{id, nome, variaveis, papeis: [.papeis[] | {id, rotulo}]}'
A resposta traz variaveis, que é a lista de marcadores encontrados no PDF, e o
id de cada papel. Guarde os dois: são o que o próximo passo consome.
// Passo 2: instanciar. Duas requisicoes de JSON puro, sem upload.
// Esta e a funcao que voce chama quando um pedido e aprovado no seu ERP.
const CHAVE = process.env.ADM_CHAVE;
const BASE = "https://api.adigitalmax.com.br/v1/sandbox";
const MODELO_ID = "3c9e7b12-4a58-4d06-9f31-2b8c5e04a7d9";
const PAPEL_CONTRATANTE = "a41f8d20-6c93-4b7e-8215-0d6a9f37c5b1";
const PAPEL_CONTRATADA = "b52a9e31-7d04-4c8f-9326-1e7b0a48d6c2";
export async function gerarContrato(pedido) {
const cabecalhos = {
Authorization: `Bearer ${CHAVE}`,
"Content-Type": "application/json",
Accept: "application/json",
};
// 1. Instanciar. Como toda criacao, nasce em RASCUNHO.
const criar = await fetch(`${BASE}/modelos/${MODELO_ID}/documentos`, {
method: "POST",
headers: {
...cabecalhos,
// O numero do pedido como chave: retentativa depois de um timeout
// nao gera um segundo contrato.
"Idempotency-Key": `contrato-pedido-${pedido.numero}`,
},
body: JSON.stringify({
titulo: `Contrato ${pedido.cliente.nome} - ${pedido.competencia}`,
pastaId: pedido.pastaId,
variaveis: {
cliente_nome: pedido.cliente.nome,
cliente_documento: pedido.cliente.cnpj,
valor_mensal: pedido.valorFormatado,
vigencia_meses: String(pedido.vigenciaMeses),
},
papeis: [
{
papelId: PAPEL_CONTRATANTE,
nome: pedido.cliente.responsavel,
email: pedido.cliente.email,
telefone: pedido.cliente.telefone, // exigido pelo fator SMS_OTP
},
{
papelId: PAPEL_CONTRATADA,
nome: "Carlos Souza",
email: "carlos@empresa.com.br",
telefone: "+5511977776666",
},
],
}),
});
const documento = await criar.json();
if (!criar.ok) {
// ENTRADA_INVALIDA aqui quase sempre e variavel faltando; o campo
// "erros" diz qual.
throw new Error(
`${documento.codigo}: ${documento.title} ${JSON.stringify(documento.erros ?? [])} ` +
`(requestId ${documento.requestId})`
);
}
// 2. Enviar, que continua sendo uma chamada separada.
const enviar = await fetch(`${BASE}/documentos/${documento.id}/enviar`, {
method: "POST",
headers: { ...cabecalhos, "Idempotency-Key": `contrato-pedido-${pedido.numero}-enviar` },
});
const enviado = await enviar.json();
if (!enviar.ok) {
throw new Error(`${enviado.codigo}: ${enviado.title} (requestId ${enviado.requestId})`);
}
return enviado;
}<?php
// Passo 2: instanciar o modelo. JSON puro, sem upload.
declare(strict_types=1);
const BASE = 'https://api.adigitalmax.com.br/v1/sandbox';
const MODELO_ID = '3c9e7b12-4a58-4d06-9f31-2b8c5e04a7d9';
const PAPEL_CONTRATANTE = 'a41f8d20-6c93-4b7e-8215-0d6a9f37c5b1';
const PAPEL_CONTRATADA = 'b52a9e31-7d04-4c8f-9326-1e7b0a48d6c2';
function postJson(string $url, array $corpo, array $extra = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpo, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => array_merge([
'Authorization: Bearer ' . getenv('ADM_CHAVE'),
'Content-Type: application/json',
'Accept: application/json',
], $extra),
CURLOPT_TIMEOUT => 60,
]);
$bruto = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$resposta = json_decode((string) $bruto, true, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) {
// ENTRADA_INVALIDA aqui quase sempre e variavel faltando.
throw new RuntimeException(sprintf(
'%s: %s %s (requestId %s)',
$resposta['codigo'] ?? '?',
$resposta['title'] ?? '?',
json_encode($resposta['erros'] ?? []),
$resposta['requestId'] ?? '?'
));
}
return $resposta;
}
function gerarContrato(array $pedido): array
{
$chaveIdem = 'contrato-pedido-' . $pedido['numero'];
// 1. Instanciar. Como toda criacao, nasce em RASCUNHO.
$documento = postJson(BASE . '/modelos/' . MODELO_ID . '/documentos', [
'titulo' => sprintf('Contrato %s - %s', $pedido['cliente']['nome'], $pedido['competencia']),
'pastaId' => $pedido['pasta_id'],
'variaveis' => [
'cliente_nome' => $pedido['cliente']['nome'],
'cliente_documento' => $pedido['cliente']['cnpj'],
'valor_mensal' => $pedido['valor_formatado'],
'vigencia_meses' => (string) $pedido['vigencia_meses'],
],
'papeis' => [
[
'papelId' => PAPEL_CONTRATANTE,
'nome' => $pedido['cliente']['responsavel'],
'email' => $pedido['cliente']['email'],
'telefone' => $pedido['cliente']['telefone'],
],
[
'papelId' => PAPEL_CONTRATADA,
'nome' => 'Carlos Souza',
'email' => 'carlos@empresa.com.br',
'telefone' => '+5511977776666',
],
],
], ['Idempotency-Key: ' . $chaveIdem]);
// 2. Enviar, que continua sendo uma chamada separada.
return postJson(
BASE . '/documentos/' . $documento['id'] . '/enviar',
[],
['Idempotency-Key: ' . $chaveIdem . '-enviar']
);
}#!/usr/bin/env bash
# Passo 2: instanciar o modelo e enviar. Duas chamadas, sem upload.
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
MODELO_ID="3c9e7b12-4a58-4d06-9f31-2b8c5e04a7d9"
PEDIDO="88231"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE" --header "Content-Type: application/json")
DOC=$(
curl --fail-with-body --silent --show-error "${AUTH[@]}" \
--request POST "$ADM_BASE/modelos/$MODELO_ID/documentos" \
--header "Idempotency-Key: contrato-pedido-$PEDIDO" \
--data @- <<'JSON'
{
"titulo": "Contrato Maria Oliveira - agosto/2026",
"variaveis": {
"cliente_nome": "Maria Oliveira",
"cliente_documento": "12.345.678/0001-90",
"valor_mensal": "R$ 2.480,00",
"vigencia_meses": "12"
},
"papeis": [
{ "papelId": "a41f8d20-6c93-4b7e-8215-0d6a9f37c5b1", "nome": "Maria Oliveira",
"email": "maria@exemplo.com.br", "telefone": "+5511988887777" },
{ "papelId": "b52a9e31-7d04-4c8f-9326-1e7b0a48d6c2", "nome": "Carlos Souza",
"email": "carlos@empresa.com.br", "telefone": "+5511977776666" }
]
}
JSON
)
DOC_ID=$(echo "$DOC" | jq -r '.id')
echo "Rascunho: $DOC_ID"
curl --fail-with-body --silent --show-error "${AUTH[@]}" \
--request POST "$ADM_BASE/documentos/$DOC_ID/enviar" \
--header "Idempotency-Key: contrato-pedido-$PEDIDO-enviar" \
| jq '{id, status}'7. Organizar em pastas
Pasta é organização, não permissão: colocar um documento em pasta não restringe quem o vê. Quem vê o quê é papel, e isso está em Autenticação.
Um desenho que funciona bem em integração é uma pasta por período e uma subpasta por tipo, criadas sob demanda pelo próprio código. Assim o acervo não vira uma lista de dez mil documentos na raiz.
// pastas.mjs: garante a arvore "2026/08/Contratos" e devolve o id da folha.
// ADM_CHAVE=... node pastas.mjs
const CHAVE = process.env.ADM_CHAVE;
const BASE = "https://api.adigitalmax.com.br/v1/sandbox";
const cabecalhos = {
Authorization: `Bearer ${CHAVE}`,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Devolve o id da pasta com este nome sob este pai, criando se faltar.
* Idempotente por natureza: rodar dez vezes nao cria dez pastas.
*/
async function garantirPasta(nome, paiId = null) {
const query = new URLSearchParams({ busca: nome, limite: "100" });
if (paiId) query.set("paiId", paiId);
const lista = await fetch(`${BASE}/pastas?${query}`, { headers: cabecalhos })
.then((r) => r.json());
// A busca e por trecho; a comparacao exata e nossa.
const existente = lista.itens.find(
(p) => p.nome === nome && (p.paiId ?? null) === paiId
);
if (existente) return existente.id;
const criada = await fetch(`${BASE}/pastas`, {
method: "POST",
headers: cabecalhos,
body: JSON.stringify({ nome, paiId }),
});
if (criada.status === 409) {
// Outro processo criou entre a leitura e a escrita. Releia.
return garantirPasta(nome, paiId);
}
const corpo = await criada.json();
if (!criada.ok) throw new Error(`${corpo.codigo}: ${corpo.title}`);
return corpo.id;
}
async function pastaDoMes(data = new Date(), tipo = "Contratos") {
const ano = String(data.getFullYear());
const mes = String(data.getMonth() + 1).padStart(2, "0");
const idAno = await garantirPasta(ano);
const idMes = await garantirPasta(mes, idAno);
return garantirPasta(tipo, idMes);
}
const destino = await pastaDoMes();
console.log("Pasta de destino:", destino);
// Mover um documento existente para la:
// await fetch(`${BASE}/documentos/${docId}/mover`, {
// method: "POST", headers: cabecalhos,
// body: JSON.stringify({ pastaId: destino }),
// });#!/usr/bin/env bash
# Cria a arvore do mes e move um documento para ela.
# ADM_CHAVE=... ./pastas.sh 6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48
set -euo pipefail
ADM_CHAVE="${ADM_CHAVE:?}"
ADM_BASE="https://api.adigitalmax.com.br/v1/sandbox"
DOC_ID="${1:?informe o id do documento}"
AUTH=(--header "Authorization: Bearer $ADM_CHAVE" --header "Content-Type: application/json")
# Devolve o id da pasta, criando se faltar. Idempotente.
garantir_pasta() {
local NOME="$1" PAI="${2:-}"
local URL="$ADM_BASE/pastas?busca=$NOME&limite=100"
[ -n "$PAI" ] && URL="$URL&paiId=$PAI"
local ID
ID=$(curl --silent "${AUTH[@]}" "$URL" \
| jq -r --arg n "$NOME" --arg p "$PAI" \
'.itens[] | select(.nome == $n) | select((.paiId // "") == $p) | .id' \
| head -n1)
if [ -n "$ID" ]; then
echo "$ID"
return
fi
local CORPO
if [ -n "$PAI" ]; then
CORPO=$(jq -nc --arg n "$NOME" --arg p "$PAI" '{nome:$n, paiId:$p}')
else
CORPO=$(jq -nc --arg n "$NOME" '{nome:$n, paiId:null}')
fi
curl --fail-with-body --silent "${AUTH[@]}" \
--request POST "$ADM_BASE/pastas" --data "$CORPO" | jq -r '.id'
}
ID_ANO=$(garantir_pasta "$(date +%Y)")
ID_MES=$(garantir_pasta "$(date +%m)" "$ID_ANO")
ID_TIPO=$(garantir_pasta "Contratos" "$ID_MES")
echo "Pasta de destino: $ID_TIPO"
curl --fail-with-body --silent "${AUTH[@]}" \
--request POST "$ADM_BASE/documentos/$DOC_ID/mover" \
--data "$(jq -nc --arg p "$ID_TIPO" '{pastaId:$p}')" \
| jq '{id, titulo, pastaId}'8. Validar a autenticidade de um documento
Esta é a única parte da API que não precisa de credencial, porque quem valida costuma ser exatamente quem não é cliente de ninguém: a contraparte, o cartório, o banco, o juiz.
O endereço é diferente do resto da API. A validação mora no host
institucional, em
POST https://adigitalmax.com.br/api/v1/validacao/consultar, e não em
api.. A página de validação fica no mesmo domínio, então a chamada é de
mesma origem e não exige CORS aberto justamente no endpoint mais exposto do sistema.
Há três formas de entrada, e ao menos uma é obrigatória: codigo,
hashArquivo ou qrCode. O limite é de 30 por minuto por IP, com
rajada de 10.
#!/usr/bin/env bash
# Valida um PDF sem enviar o arquivo: manda so o resumo SHA-256.
# Sem chave de API, de proposito.
# ./validar.sh ./contrato-assinado.pdf
set -euo pipefail
VALIDACAO="https://adigitalmax.com.br/api/v1/validacao/consultar"
PDF="${1:?informe o caminho do PDF}"
HASH=$(sha256sum "$PDF" | cut -d' ' -f1)
echo "sha256: $HASH"
curl --silent --show-error \
--request POST "$VALIDACAO" \
--header "Content-Type: application/json" \
--data "$(jq -nc --arg h "$HASH" '{hashArquivo:$h}')" \
| jq '{resultado,
documento: .documento.nome,
assinantes: [.signatarios[] | "\(.nome) em \(.assinadoEm)"]}'
# Validar pelo codigo impresso, quando so ha o papel na mao:
# curl --silent --request POST "$VALIDACAO" \
# --header "Content-Type: application/json" \
# --data '{"codigo":"ADM-7K4Q-2M9X-B3TD"}' | jq .// validar-no-navegador.js
// Calcula o hash NO NAVEGADOR e envia so os 64 caracteres. O PDF nunca
// sai da maquina de quem valida, o que importa porque quem valida
// costuma nao confiar na outra parte, e nao teria por que confiar na
// plataforma dela tambem. Precisa de contexto seguro (https ou localhost).
//
// Nenhuma credencial. Este endpoint e publico de proposito.
const VALIDACAO = "https://adigitalmax.com.br/api/v1/validacao/consultar";
async function sha256Hex(arquivo) {
const bytes = await arquivo.arrayBuffer();
const resumo = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(resumo))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function consultar(corpo) {
const resposta = await fetch(VALIDACAO, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corpo),
});
if (resposta.status === 429) {
throw new Error("Muitas validacoes deste endereco. Tente em um minuto.");
}
return resposta.json();
}
export const validarArquivo = async (arquivo) =>
consultar({ hashArquivo: await sha256Hex(arquivo) });
export const validarCodigo = (codigo) =>
// Aceita com ou sem hifen, maiuscula ou minuscula.
consultar({ codigo: codigo.trim().toUpperCase().replace(/\s/g, "") });
export const validarQr = (qrCode) => consultar({ qrCode });
// Uso em um :
//
// document.querySelector("#pdf").addEventListener("change", async (e) => {
// const r = await validarArquivo(e.target.files[0]);
//
// switch (r.resultado) {
// case "AUTENTICO":
// // O registro existe e o hash bate. E o mesmo arquivo assinado.
// mostrar("Documento autentico", r.signatarios);
// break;
// case "ADULTERADO":
// // O codigo existe e o hash NAO bate. Unico desfecho que afirma
// // algo negativo sobre o arquivo, e so aparece com hash.
// mostrar("Este arquivo foi alterado depois de assinado.");
// break;
// case "NAO_ENCONTRADO":
// // NAO significa que o documento e falso: pode ter sido assinado
// // em outra plataforma, expurgado por retencao, ou o codigo pode
// // ter sido digitado errado. Diga isso com essas palavras.
// mostrar("Nao temos registro deste documento. Isso nao significa "
// + "que ele seja falso.");
// break;
// }
// });#!/usr/bin/env python3
"""Valida um PDF pelo hash, sem enviar o arquivo e sem credencial.
pip install requests
python3 validar.py ./contrato-assinado.pdf
"""
import hashlib
import sys
import requests
VALIDACAO = "https://adigitalmax.com.br/api/v1/validacao/consultar"
def sha256_do_arquivo(caminho: str) -> str:
resumo = hashlib.sha256()
with open(caminho, "rb") as arquivo:
# Em blocos: PDF de 20 MB nao precisa caber inteiro na memoria.
for bloco in iter(lambda: arquivo.read(1024 * 1024), b""):
resumo.update(bloco)
return resumo.hexdigest()
def consultar(**corpo) -> dict:
return requests.post(VALIDACAO, json=corpo, timeout=30).json()
if __name__ == "__main__":
resultado = consultar(hashArquivo=sha256_do_arquivo(sys.argv[1]))
if resultado["resultado"] == "AUTENTICO":
print("AUTENTICO:", resultado["documento"]["nome"])
print("codigo:", resultado.get("codigo"))
for s in resultado["signatarios"]:
print(f" {s['nome']} <{s['emailMascarado']}> "
f"assinou em {s['assinadoEm']} usando {s['autenticacao']}")
elif resultado["resultado"] == "ADULTERADO":
# O codigo existe e o hash nao bate. E o caso mais grave.
print("ADULTERADO: este arquivo foi alterado depois de assinado.")
sys.exit(2)
else:
# NAO_ENCONTRADO nao equivale a falso.
print("Nao temos registro deste documento. Isso nao significa que "
"ele seja falso: pode ter sido assinado em outra plataforma.")
sys.exit(3)<?php
// validar.php: valida pelo hash, sem enviar o arquivo e sem credencial.
// php validar.php ./contrato-assinado.pdf
declare(strict_types=1);
const VALIDACAO = 'https://adigitalmax.com.br/api/v1/validacao/consultar';
function consultar(array $corpo): array
{
$ch = curl_init(VALIDACAO);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($corpo, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 30,
]);
$bruto = curl_exec($ch);
curl_close($ch);
return json_decode((string) $bruto, true, 512, JSON_THROW_ON_ERROR);
}
$pdf = $argv[1] ?? exit("Informe o caminho do PDF.\n");
// hash_file le em blocos: PDF de 20 MB nao precisa caber na memoria.
$resultado = consultar(['hashArquivo' => hash_file('sha256', $pdf)]);
switch ($resultado['resultado']) {
case 'AUTENTICO':
printf("AUTENTICO: %s\n", $resultado['documento']['nome']);
foreach ($resultado['signatarios'] as $s) {
printf(" %s <%s> assinou em %s usando %s\n",
$s['nome'], $s['emailMascarado'], $s['assinadoEm'], $s['autenticacao']);
}
break;
case 'ADULTERADO':
// O codigo existe e o hash nao bate. E o caso mais grave.
fwrite(STDERR, "ADULTERADO: este arquivo foi alterado depois de assinado.\n");
exit(2);
default:
// NAO_ENCONTRADO nao equivale a falso.
fwrite(STDERR, "Nao temos registro deste documento. Isso nao significa que "
. "ele seja falso: pode ter sido assinado em outra plataforma.\n");
exit(3);
}
Para o seu usuário final, o caminho mais simples é o link
https://adigitalmax.com.br/validar, que já faz tudo isto em uma página
pública: aceita código, aceita o PDF calculando o hash no navegador, e lê QR Code. Você
não precisa construir tela nenhuma; basta apontar para lá.