Comece AquiAssinatura de Requisições

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:

algTipo de chaveObservação
rsa-v1_5-sha256RSA, 2048 bits ou maisRecomendado. É o SHA256withRSA do Java e o RSA-SHA256 do Node.js
rsa-pss-sha512RSAPSS com salt de 64 bytes
ecdsa-p256-sha256EC, curva P-256A assinatura vai no formato r + s, com 64 bytes, e não em DER
ed25519Ed25519

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:

RecursoOperaçãoRota
AnexosCriar anexoPOST /attachments/v1/attachments
CategoriasCriar categoriaPOST /categories/v1/categories
CategoriasAtualizar categoriaPATCH /categories/v1/categories/{id}
CategoriasExcluir categoriasDELETE /categories/v1/categories
Centros de custoCriar centro de custoPOST /cost-centers/v1/cost-centers
Centros de custoAtualizar centro de custoPATCH /cost-centers/v1/cost-centers/{id}
Centros de custoExcluir centro de custoDELETE /cost-centers/v1/cost-centers/{id}
CartõesBloquear cartãoPOST /credit-cards/v1/{id}/block
CartõesDesbloquear cartãoPOST /credit-cards/v1/{id}/unblock
VCNCriar VCNPOST /credit-cards/v1/vcns
VCNAtualizar VCNPATCH /credit-cards/v1/vcns/{vcnId}
VCNRevelar dados do cartãoGET /credit-cards/v1/vcns/{vcnId}/reveal
TransaçõesAtualizar transação bancáriaPATCH /statements/v1/banking/{transactionId}
TransaçõesAtualizar transação de cartãoPATCH /statements/v1/credit-card/{transactionId}
FornecedoresCriar fornecedorPOST /suppliers/v1/suppliers
FornecedoresAtualizar fornecedorPUT /suppliers/v1/suppliers/{id}
FornecedoresExcluir fornecedorDELETE /suppliers/v1/suppliers/{id}
UsuáriosCriar convitePOST /users/v1/invites
UsuáriosAtualizar usuárioPATCH /users/v1/users/{userId}
UsuáriosExcluir usuárioDELETE /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>:
HeaderConteúdo
Content-DigestHash SHA-256 do corpo, em base64 e entre :, como define a RFC 9530. Só vai quando a requisição tem corpo
Signature-InputA lista do que foi assinado e os parâmetros da assinatura, identificados por um rótulo (sig)
SignatureA 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

ComponenteValorQuando
"@method"Método em maiúsculas, por exemplo POSTSempre
"@path"Path sem o host e sem a query, por exemplo /statements/v1/bankingSempre
"@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-DigestQuando há corpo
ParâmetroUso
createdObrigatório. Horário Unix em segundos. A API aceita até 300 segundos de diferença do relógio dela
algObrigatório. Tem que ser compatível com a chave registrada (veja Formato aceito)
nonceOpcional, mas recomendado. Use um valor diferente em cada requisição, como um UUID
expiresOpcional. A assinatura é recusada depois desse horário
keyidOpcional. 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.


Erros


Boas práticas


Próximos passos