Assinatura de Requisições
Como assinar chamadas à API com sua chave privada e registrar a chave pública na credencial
Visão geral
Uma credencial com chave assimétrica assina cada requisição com a chave privada, além de enviar o token OAuth. O token identifica a credencial. A assinatura mostra que a requisição saiu de quem tem a chave privada e que o método, a rota e o corpo não foram alterados no caminho.
Assim, um token vazado não basta para usar a credencial. Sem a chave privada, não dá para assinar uma requisição nova, e uma assinatura interceptada não serve para outra requisição. Mais detalhes em O que a assinatura cobre.
Credenciais sem chave pública não assinam as chamadas. Quando a credencial tem chave pública registrada, todas as chamadas dela precisam ser assinadas. A chave é obrigatória para receber escopos sensíveis, que são os de escrita e os de leitura que expõem dados sensíveis, como os dados completos de um cartão virtual.
Como funciona o par de chaves
Você gera o par no seu ambiente
São duas chaves ligadas entre si: uma privada e uma pública.
A chave privada fica no seu ambiente
Ela assina cada requisição. A Conta Simples não recebe nem pede essa chave, e o suporte nunca vai pedir que você a envie.
A chave pública é registrada na credencial
A Conta Simples usa a chave pública para conferir a assinatura. Com ela não dá para assinar nada em seu nome, então não há risco em compartilhá-la.
A chave fica ligada àquela credencial
Para trocar de chave, crie outra credencial com a chave nova e revogue a anterior. Rotacionar a API secret não altera a chave pública.
Formato aceito
A chave pública é registrada em PEM (X.509 SubjectPublicKeyInfo), com as linhas -----BEGIN PUBLIC KEY----- e -----END PUBLIC KEY-----. O algoritmo informado no alg da assinatura tem que ser compatível com o tipo da chave:
alg | Tipo de chave | Observação |
|---|---|---|
rsa-v1_5-sha256 | RSA, 2048 bits ou mais | Recomendado. É o SHA256withRSA do Java e o RSA-SHA256 do Node.js |
rsa-pss-sha512 | RSA | PSS com salt de 64 bytes |
ecdsa-p256-sha256 | EC, curva P-256 | A assinatura vai no formato r + s, com 64 bytes, e não em DER |
ed25519 | Ed25519 |
Todos os exemplos desta página usam rsa-v1_5-sha256. O card do playground só aceita chaves RSA.
Gerando o par
Primeiro, gere a chave privada. Guarde o arquivo em um cofre e não compartilhe com ninguém.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private-key.pem
Depois, gere a chave pública a partir dela. É o conteúdo do public-key.pem que você registra na credencial.
openssl rsa -in private-key.pem -pubout -out public-key.pem
openssl genrsa também gera uma chave válida, mas o formato do arquivo depende da versão. O
openssl que vem no macOS (LibreSSL) grava em PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) e o
OpenSSL 3 grava em PKCS#8 (-----BEGIN PRIVATE KEY-----). As duas assinam do mesmo jeito e
geram a mesma chave pública, mas algumas bibliotecas só aceitam PKCS#8. O genpkey acima grava
PKCS#8 em qualquer versão. Para converter uma chave que você já tem:
openssl pkcs8 -topk8 -nocrypt -in private-key.pem -out private-key-pkcs8.pem.
Trate a chave privada com o mesmo cuidado da API secret. Guarde em cofre (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) e carregue em runtime. Não versione, não registre em log e não cole em formulários.
Registrando a chave pública
O registro é feito no painel de credenciais do Internet Banking, ao criar a credencial.
Nomeie a credencial e escolha o ambiente
Sandbox ou Produção. Cada ambiente tem credenciais e chaves próprias.
Selecione os escopos
Há um escopo por rota, agrupados por recurso. A credencial só acessa as rotas dos escopos concedidos, então conceda apenas o que a integração usa.
Cole a chave pública
O campo fica obrigatório quando algum escopo selecionado exige requisição assinada. Cole o
conteúdo completo do public-key.pem, com as linhas BEGIN e END.
Confirme e guarde o par API key / API secret
A API secret pode ser consultada depois no painel, mas o ideal é copiá-la direto para o seu cofre na primeira exibição.
Se você colar uma chave privada por engano, o navegador recusa antes de enviar e mostra a mensagem
"Este conteúdo é uma chave privada". Nesse caso, cole o arquivo gerado pelo comando com -pubout.
Quando a assinatura é exigida
Quando a credencial tem chave pública registrada, todas as chamadas feitas com ela precisam ser assinadas, inclusive as leituras. Sem assinatura, a API responde 401, mesmo num simples GET de saldo. A única chamada que não se assina é a emissão do token (POST /oauth/v1/access-token), que usa Basic auth.
Alguns escopos são sensíveis e só podem ser concedidos a uma credencial com chave pública. São os escopos de escrita, que alteram dados da empresa, e os de leitura que expõem dados sensíveis. Esta é a lista completa:
| Recurso | Operação | Rota |
|---|---|---|
| Anexos | Criar anexo | POST /attachments/v1/attachments |
| Categorias | Criar categoria | POST /categories/v1/categories |
| Categorias | Atualizar categoria | PATCH /categories/v1/categories/{id} |
| Categorias | Excluir categorias | DELETE /categories/v1/categories |
| Centros de custo | Criar centro de custo | POST /cost-centers/v1/cost-centers |
| Centros de custo | Atualizar centro de custo | PATCH /cost-centers/v1/cost-centers/{id} |
| Centros de custo | Excluir centro de custo | DELETE /cost-centers/v1/cost-centers/{id} |
| Cartões | Bloquear cartão | POST /credit-cards/v1/{id}/block |
| Cartões | Desbloquear cartão | POST /credit-cards/v1/{id}/unblock |
| VCN | Criar VCN | POST /credit-cards/v1/vcns |
| VCN | Atualizar VCN | PATCH /credit-cards/v1/vcns/{vcnId} |
| VCN | Revelar dados do cartão | GET /credit-cards/v1/vcns/{vcnId}/reveal |
| Transações | Atualizar transação bancária | PATCH /statements/v1/banking/{transactionId} |
| Transações | Atualizar transação de cartão | PATCH /statements/v1/credit-card/{transactionId} |
| Fornecedores | Criar fornecedor | POST /suppliers/v1/suppliers |
| Fornecedores | Atualizar fornecedor | PUT /suppliers/v1/suppliers/{id} |
| Fornecedores | Excluir fornecedor | DELETE /suppliers/v1/suppliers/{id} |
| Usuários | Criar convite | POST /users/v1/invites |
| Usuários | Atualizar usuário | PATCH /users/v1/users/{userId} |
| Usuários | Excluir usuário | DELETE /users/v1/users/{userId} |
Uma credencial sem nenhum escopo desta lista pode ser criada sem chave pública, e aí nenhuma chamada dela é assinada. Se a credencial tiver algum escopo sensível, todas as chamadas dela são assinadas.
Como assinar
A assinatura segue a RFC 9421 (HTTP Message Signatures). Uma requisição assinada leva estes headers:
Content-Digest: sha-256=:yFX5lxxfVgRuAzQoK0R4Y88OcevNt0CU1eEb6eXq0T4=:
Signature-Input: sig=("@method" "@path" "@query" "content-digest");created=1757337600;alg="rsa-v1_5-sha256";nonce="3f0c9a4e-7b1d-4c8e-9f2a-5d6b8c1e0a47"
Signature: sig=:<assinatura em base64>:
| Header | Conteúdo |
|---|---|
Content-Digest | Hash SHA-256 do corpo, em base64 e entre :, como define a RFC 9530. Só vai quando a requisição tem corpo |
Signature-Input | A lista do que foi assinado e os parâmetros da assinatura, identificados por um rótulo (sig) |
Signature | A assinatura em base64, entre :, com o mesmo rótulo |
Cada requisição leva uma assinatura só. Se o Signature-Input tiver mais de um rótulo, a API recusa.
Componentes e parâmetros
| Componente | Valor | Quando |
|---|---|---|
"@method" | Método em maiúsculas, por exemplo POST | Sempre |
"@path" | Path sem o host e sem a query, por exemplo /statements/v1/banking | Sempre |
"@query" | Query com o ? do começo, por exemplo ?startDate=2025-01-01. Se não houver query, vale só ? | Sempre |
"content-digest" | O mesmo valor enviado no header Content-Digest | Quando há corpo |
| Parâmetro | Uso |
|---|---|
created | Obrigatório. Horário Unix em segundos. A API aceita até 300 segundos de diferença do relógio dela |
alg | Obrigatório. Tem que ser compatível com a chave registrada (veja Formato aceito) |
nonce | Opcional, mas recomendado. Use um valor diferente em cada requisição, como um UUID |
expires | Opcional. A assinatura é recusada depois desse horário |
keyid | Opcional. Não é necessário enviar |
A base de assinatura
O texto assinado é a base de assinatura da RFC 9421. Ela tem uma linha para cada componente, na mesma ordem do Signature-Input, e termina com a linha "@signature-params". As linhas são separadas por e não há quebra de linha no fim. No POST /suppliers/v1/suppliers do exemplo acima, a base fica assim:
"@method": POST
"@path": /suppliers/v1/suppliers
"@query": ?
"content-digest": sha-256=:yFX5lxxfVgRuAzQoK0R4Y88OcevNt0CU1eEb6eXq0T4=:
"@signature-params": ("@method" "@path" "@query" "content-digest");created=1757337600;alg="rsa-v1_5-sha256";nonce="3f0c9a4e-7b1d-4c8e-9f2a-5d6b8c1e0a47"
A assinatura é calculada com o algoritmo do alg sobre essa base em UTF-8. Os erros mais comuns ao montar a base estão abaixo.
O que a assinatura cobre
A assinatura cobre o método, o path, a query, o corpo (por meio do Content-Digest) e o horário em que foi criada. Por isso, uma assinatura interceptada não pode ser usada numa requisição diferente.
Envie um nonce diferente em cada requisição. E não grave os headers Signature-Input e Signature em logs, proxies ou ferramentas de APM, assim como você já não grava o token.
Exemplos
Node.js
import { createHash, createSign, randomUUID } from "node:crypto";
// A chave privada vem do seu cofre em runtime.
const PRIVATE_KEY = process.env.CS_SIGNATURE_PRIVATE_KEY!;
const BASE_URL = process.env.CS_BASE_URL!; // https://api-sandbox.contasimples.com
const USER_AGENT = "meu-app/1.0";
type SignedRequest = {
method: string;
target: string; // path com a query, já codificada, como vai na URL
token: string;
payload?: unknown;
};
export async function signedRequest({
method,
target,
token,
payload
}: SignedRequest): Promise<Response> {
// Serializa uma vez só. O digest e o envio usam a mesma string.
const body = payload === undefined ? "" : JSON.stringify(payload);
return fetch(`${BASE_URL}${target}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
...signatureHeaders(method, target, body)
},
// Sem corpo, envia undefined. Enviar "" muda o que o servidor recebe.
body: body || undefined
});
}
function signatureHeaders(method: string, target: string, body: string): Record<string, string> {
const queryStart = target.indexOf("?");
const path = queryStart === -1 ? target : target.slice(0, queryStart);
const query = queryStart === -1 ? "?" : target.slice(queryStart);
const components = ['"@method"', '"@path"', '"@query"'];
const lines = [`"@method": ${method.toUpperCase()}`, `"@path": ${path}`, `"@query": ${query}`];
const headers: Record<string, string> = {};
if (body) {
headers["Content-Digest"] = `sha-256=:${createHash("sha256").update(body, "utf8").digest("base64")}:`;
components.push('"content-digest"');
lines.push(`"content-digest": ${headers["Content-Digest"]}`);
}
const created = Math.floor(Date.now() / 1000);
const params = `(${components.join(" ")});created=${created};alg="rsa-v1_5-sha256";nonce="${randomUUID()}"`;
lines.push(`"@signature-params": ${params}`);
const signature = createSign("RSA-SHA256")
.update(lines.join("
"), "utf8")
.sign(PRIVATE_KEY, "base64");
headers["Signature-Input"] = `sig=${params}`;
headers["Signature"] = `sig=:${signature}:`;
return headers;
}
Uso:
// Escrita com corpo.
await signedRequest({
method: "POST",
target: "/suppliers/v1/suppliers",
token: accessToken,
payload: { name: "Fornecedor Exemplo", document: "00000000000191" }
});
// Leitura também é assinada. A query é montada uma vez e usada no path assinado.
const query = new URLSearchParams({ startDate: "2025-01-01", endDate: "2025-01-31" });
await signedRequest({
method: "GET",
target: `/statements/v1/banking?${query}`,
token: accessToken
});
Se a chave estiver numa variável de ambiente com escrito no lugar das quebras de linha,
converta antes de usar com PRIVATE_KEY.replace(/\ /g, " "). Sem as quebras de linha, o PEM
não carrega.
Shell (openssl)
TOKEN="${CS_ACCESS_TOKEN}"
BASE_URL="https://api-sandbox.contasimples.com"
METHOD="POST"
TARGET_PATH="/suppliers/v1/suppliers"
QUERY="?"
BODY='{"name":"Fornecedor Exemplo","document":"00000000000191"}'
CREATED=$(date +%s)
NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')
# printf e não echo, porque o echo acrescenta
no fim e o digest não confere.
DIGEST="sha-256=:$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | openssl base64 -A):"
PARAMS="("@method" "@path" "@query" "content-digest");created=${CREATED};alg="rsa-v1_5-sha256";nonce="${NONCE}""
# O -A deixa o base64 numa linha só. Com quebras de linha, o header é recusado antes de chegar à API.
SIGNATURE=$(printf '"@method": %s
"@path": %s
"@query": %s
"content-digest": %s
"@signature-params": %s' \
"$METHOD" "$TARGET_PATH" "$QUERY" "$DIGEST" "$PARAMS" \
| openssl dgst -sha256 -sign private-key.pem \
| openssl base64 -A)
# --data-binary e não -d, porque o -d tira as quebras de linha do corpo.
curl -X "$METHOD" "${BASE_URL}${TARGET_PATH}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-H "User-Agent: meu-app/1.0" \
-H "Content-Digest: ${DIGEST}" \
-H "Signature-Input: sig=${PARAMS}" \
-H "Signature: sig=:${SIGNATURE}:" \
--data-binary "$BODY"
Numa leitura com query, a query é assinada do jeito que vai na URL e não há Content-Digest:
TARGET_PATH="/statements/v1/banking"
QUERY="?startDate=2025-01-01&endDate=2025-01-31"
CREATED=$(date +%s)
NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')
PARAMS="("@method" "@path" "@query");created=${CREATED};alg="rsa-v1_5-sha256";nonce="${NONCE}""
SIGNATURE=$(printf '"@method": GET
"@path": %s
"@query": %s
"@signature-params": %s' \
"$TARGET_PATH" "$QUERY" "$PARAMS" \
| openssl dgst -sha256 -sign private-key.pem \
| openssl base64 -A)
curl -X GET "${BASE_URL}${TARGET_PATH}${QUERY}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "User-Agent: meu-app/1.0" \
-H "Signature-Input: sig=${PARAMS}" \
-H "Signature: sig=:${SIGNATURE}:"
Testando pelo playground da API Reference
O botão Try it de cada endpoint faz uma chamada de verdade à API, então precisa de assinatura quando a credencial tem chave pública. Dentro do Try it, logo abaixo de Headers, fica o card Assinatura da requisição. Na página de emissão do token o card não aparece, porque essa chamada não é assinada.
Cole a chave privada da credencial de Sandbox
O card aceita chaves RSA em PKCS#8 (BEGIN PRIVATE KEY) ou PKCS#1 (BEGIN RSA PRIVATE KEY).
Confira a impressão digital
O card mostra um identificador da chave com 8 caracteres e a chave pública correspondente. Confira se é a mesma que está registrada na credencial.
Envie normalmente
A cada envio, o card calcula o Signature-Input, o Signature e, se houver corpo, o
Content-Digest daquela chamada. Os headers do último envio aparecem no card, com um botão
para copiar.
Use só chave de Sandbox. Nunca cole em uma página web a chave privada da sua integração de produção, nem nesta. O playground envia a requisição pelo servidor do portal de documentação, e o token e o corpo passam por lá.
A chave fica só na memória da aba e não é gravada em lugar nenhum. Se você recarregar a página, precisa colar de novo. Também dá para testar sem colar a chave no card. Os campos Signature-Input, Signature e Content-Digest estão no formulário, e você pode preencher com valores gerados pelos exemplos acima. A assinatura precisa ter sido feita sobre a mesma rota e a mesma query do formulário, e o envio tem que acontecer em até 300 segundos.
Conferindo a assinatura antes de chamar a API
Quando uma chamada for recusada, verifique a assinatura na sua máquina com a chave pública registrada. Usando as variáveis do exemplo de shell, grave a base num arquivo e rode a verificação. Se falhar aqui, o problema está no jeito como a assinatura foi feita.
printf '"@method": %s
"@path": %s
"@query": %s
"content-digest": %s
"@signature-params": %s' \
"$METHOD" "$TARGET_PATH" "$QUERY" "$DIGEST" "$PARAMS" > signature-base.txt
printf '%s' "$SIGNATURE" | openssl base64 -d -A > signature.bin
openssl dgst -sha256 -verify public-key.pem -signature signature.bin signature-base.txt
# Verified OK
Verification failure quer dizer que a base assinada não é a mesma que você montou agora. Normalmente é uma quebra de linha sobrando, o "@signature-params" diferente do Signature-Input, a query montada de outro jeito ou o corpo serializado duas vezes.