Assinar PDFs no seu sistema
O fluxo completo: o seu navegador, o seu servidor e a API, para um documento ou um lote.
Este guia mostra o fluxo completo para assinar um ou vários PDFs no seu sistema. Se você ainda não fez uma assinatura de ponta a ponta, comece pela Primeira assinatura.
A arquitetura#
O navegador do usuário não fala com a API. Ele chama três endpoints do seu servidor, e o seu servidor chama a API com a chave de API:
Navegador (JS + extensão) Seu servidor API ShieldSign PKI
───────────────────────── ──────────── ──────────────────
1 envia o(s) PDF(s) ───────────▶ POST /assinar/iniciar ────────▶ POST /v1/pades-signatures/multipart
◀────────── { assinaturaId, documentos } ◀─ { token, documents }
2 lê o certificado (DER)
──────────────────────────────▶ POST /assinar/preparar ──────▶ POST .../{token}/prepare
◀────────── { digests } ◀─ { digests, certificate }
3 assina com a extensão: uma aprovação, um PIN, a chave nunca sai do token
4 envia as assinaturas ────────▶ POST /assinar/concluir ───────▶ POST .../{token}/complete
baixa os PDFs ◀──────────────── GET .../documents/{i} (ou .zip)
◀────────── { documentos } DELETE .../{token}
Os nomes /assinar/... são do seu sistema: use os que fizerem sentido. O que importa é o desenho:
- A chave de API e o
tokenda sessão ficam só no servidor. O navegador conhece apenas um identificador seu e opaco (assinaturaId). - O multipart é o passo de início (
POST /v1/pades-signatures/multipart). Opreparee ocompletesão os mesmos para um ou para N documentos. - Num lote, um único PIN e uma única aprovação assinam todos os documentos.
O servidor (PHP)#
O exemplo usa o cliente PHP. Com outra linguagem, os mesmos três passos são chamadas HTTP comuns: veja a Referência da API.
use ShieldSign\Client;
use ShieldSign\ApiException;
function cliente(): Client {
return new Client(getenv('SHIELDSIGN_URL'), getenv('SHIELDSIGN_KEY'), timeoutSeconds: 300);
}
1. Iniciar: /assinar/iniciar#
// $arquivos: lista de ['path' => caminho do PDF no disco, 'name' => nome do arquivo]
$r = cliente()->startMultipart($arquivos, 'pades-icpbrasil-ad-rb', 'icp-brasil', callbackArgument: "usuario:$uid");
$id = bin2hex(random_bytes(16)); // identificador opaco que o navegador vai conhecer
guardarAssinatura($id, [
'token' => $r['token'], // o token da API fica SÓ AQUI
'documentos' => $r['documents'], // transactionId e sha256 de cada PDF recebido
'documentoEsperado' => $cpfOuCnpjDoSignatario, // do seu cadastro: veja "Recusar antes do PIN"
]);
// devolva ao navegador: { assinaturaId: $id, documentos: [...] }
O arquivo vai do disco para a API em blocos, sem base64 e sem carregar o PDF na memória do PHP. Para um único PDF pequeno (até cerca de 24 MiB), startJson($bytes, $politica, $contexto) é mais simples.
Guarde o token com segurança
Em fluxos por link com código de confirmação, guarde o token no seu banco, cifrado (por exemplo, com sodium_crypto_secretbox), amarrado ao identificador da assinatura. Não use a sessão PHP se o signatário não estiver logado.
2. Preparar: /assinar/preparar#
$a = lerAssinatura($in['assinaturaId']);
$p = cliente()->prepare($a['token'], $in['certificado']); // certificado DER em base64, enviado pelo navegador
// A API já validou cadeia, validade e uso da chave. Agora confira QUEM é:
$c = $p['certificate'];
$pk = $c['pkiBrazil']; // cpf, cnpj, responsibleCpf, companyName...
if ($a['documentoEsperado'] && !in_array($a['documentoEsperado'], [$pk['cpf'], $pk['cnpj'], $pk['responsibleCpf']], true)) {
cliente()->discard($a['token']); // recusa ANTES do PIN: nada foi assinado nem cobrado
responder(422, ['erro' => 'certificado_de_outra_pessoa']);
}
$a['idempotencyKey'] = bin2hex(random_bytes(16)); // veja "Sessões, prazos e repetição segura"
guardarAssinatura($in['assinaturaId'], $a);
// devolva SÓ o necessário ao navegador: os dados a assinar, um por documento, na mesma ordem do envio
responder(200, ['digests' => $p['digests'], 'warnings' => $p['warnings'] ?? []]);
Cada item de digests traz o index, o toSignData (base64) e o digestAlgorithm. Com um documento, esses campos também vêm no topo da resposta.
3. Concluir: /assinar/concluir#
$c = cliente()->complete($a['token'], $in['assinaturas'], 'link', $a['idempotencyKey']);
foreach ($c['documents'] as $d) {
$destino = "$dir/assinado-{$d['index']}.pdf";
cliente()->downloadDocument($a['token'], $d['index'], $destino);
// confira o que recebeu: o hash do arquivo baixado deve ser o que a API informou
if (!hash_equals($d['signedSha256'], hash_file('sha256', $destino))) {
throw new RuntimeException('arquivo assinado corrompido no download');
}
// guarde: $d['transactionId'], $d['signedSha256'], $c['certificate']['pkiBrazil'] e o requestId
}
cliente()->discard($a['token']); // libera a sessão e apaga os arquivos do servidor da API
Alternativas para entregar o resultado:
| Saída | Como pedir | Quando usar |
|---|---|---|
link |
complete(..., 'link') |
Padrão em lote. Baixe cada PDF com downloadDocument ou todos num .zip com downloadZip. |
inline |
complete(...) (padrão com um documento) |
Um PDF: o signedPdf vem em base64 na própria resposta e a sessão é apagada na hora. |
O navegador (JavaScript)#
O código abaixo roda no navegador, usa o SDK JavaScript para falar com a extensão e chama só os seus endpoints. Nenhuma chave de API existe aqui.
import { ExtensionSigner, A3Signer, ExtensionError } from "/assets/shieldsign-sdk.browser.mjs";
const b64 = (bytes) => btoa(String.fromCharCode(...bytes)); // para arquivos grandes, converta em pedaços
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
async function post(caminho, corpo) {
const res = await fetch(caminho, {
method: "POST", credentials: "include",
...(corpo instanceof FormData ? { body: corpo } : { headers: { "content-type": "application/json" }, body: JSON.stringify(corpo) }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(json.mensagem ?? `HTTP ${res.status}`), { codigo: json.erro, requestId: json.requestId });
return json;
}
export async function assinar(arquivos, signer) {
// 1) iniciar: envia os PDFs
const form = new FormData();
arquivos.forEach((f) => form.append("pdf[]", f, f.name));
const ini = await post("/assinar/iniciar", form);
// 2) preparar: envia o certificado; a API o valida ANTES de o usuário digitar o PIN
const prep = await post("/assinar/preparar", { assinaturaId: ini.assinaturaId, certificado: b64(await signer.certificate()) });
// 3) assinar: UMA aprovação e UM PIN para todos os documentos
const algoritmo = prep.digests[0].digestAlgorithm;
const dados = prep.digests.map((d) => unb64(d.toSignData));
const assinaturas = await signer.signMany(dados, algoritmo);
// 4) concluir
return post("/assinar/concluir", { assinaturaId: ini.assinaturaId, assinaturas: assinaturas.map(b64) });
}
// uso
if (await ExtensionSigner.isAvailable()) {
const [cert] = await ExtensionSigner.listCertificates(); // normalmente você mostra a lista e o usuário escolhe
const resultado = await assinar([...inputArquivos.files], new A3Signer(cert.thumbprint));
}
Trate os erros da extensão (ExtensionError) separadamente dos do seu servidor: o primeiro vem do computador do usuário (cancelou, PIN errado, extensão ausente), o segundo traz o código estável da API em erro. Veja Extensão e agente e Erros.
O que o seu sistema deve fazer#
- Autenticar o usuário antes de cada endpoint. Sem login, ninguém assina nem gasta cota. Proteja os três
POSTcontra CSRF. - Manter o
tokenno servidor e entregar ao navegador só o identificador opaco. - Comparar o CPF/CNPJ do certificado com o esperado no
prepare, antes do PIN. - Conferir o SHA-256 do arquivo baixado contra
signedSha256antes de gravar. - Registrar o
X-Request-Idde cada erro (ApiException::$requestId). - Gerar o
Idempotency-Keynopreparee reutilizá-lo nocomplete; guardar o resultado da conclusão para devolvê-lo se o navegador repetir. - Apagar a sessão (
discard) ao terminar.
O que a API devolve para guardar#
Guarde junto de cada documento:
| Campo | O que é |
|---|---|
transactionId |
Identificador público da transação, que aparece no painel e na auditoria. |
sha256 |
SHA-256 do PDF que a API recebeu: compare com o seu. |
signedSha256 |
SHA-256 do PDF assinado. |
certificate.pkiBrazil |
CPF/CNPJ e demais dados do certificado que de fato assinou, lidos pelo servidor. Use este bloco para auditoria. |
X-Request-Id (cabeçalho) |
Identifica a chamada para o suporte. |
O PDF assinado preserva os bytes do original (atualização incremental): o arquivo assinado começa com o seu PDF original.
Lote é tudo ou nada
No complete de um lote, se uma assinatura for recusada (invalid_signature), nada é gerado: a mensagem diz documento N: ... e a sessão continua válida para uma nova tentativa. A cobrança conta por documento concluído, e o lote precisa caber na cota restante do mês (403 quota_exceeded no início, se não couber).
Ajustes do PHP e do servidor web#
| Item | Por quê |
|---|---|
upload_max_filesize e post_max_size |
Devem comportar o maior lote, senão o PHP descarta o upload antes de chamar a API. |
max_execution_time e o timeout do proxy (proxy_read_timeout, fastcgi_read_timeout) |
Pelo menos 120 s: o complete de PDF grande, com carimbo de tempo, pode levar segundos. |
memory_limit |
Não precisa crescer: os arquivos vão por streaming. |
client_max_body_size (nginx) |
Igual ao upload. |
| Disco temporário do PHP | Recebe o upload antes de ir à API; apague depois do startMultipart. |
Outras linguagens#
A API é HTTP puro. Em Node, use o ShieldSignClient do SDK JavaScript; nas demais linguagens, siga a Referência da API e o fluxo deste guia.