Get startedRequest Signing

Request Signing

Sign API calls with your private key and register the public key on your credential

Overview

A credential with an asymmetric key signs every request with its private key, on top of sending the OAuth token. The token identifies the credential. The signature shows that the request came from whoever holds the private key and that the method, route, and body were not changed on the way.

This way a leaked token is not enough to use the credential. Without the private key, nobody can sign a new request, and an intercepted signature does not work for a different request. More in What the signature covers.

Credentials without a public key do not sign their calls. When a credential has a public key registered, all of its calls must be signed. The key is required to receive sensitive scopes, which are the write scopes and the read scopes that expose sensitive data, such as the full data of a virtual card.


How the key pair works

You generate the pair in your environment

It is made of two linked keys, one private and one public.

The private key stays in your environment

It signs every request. Conta Simples never receives or asks for this key, and support will never ask you to send it.

The public key is registered on the credential

Conta Simples uses the public key to check the signature. Nobody can sign anything on your behalf with it, so it is safe to share.

The key is bound to that credential

To change keys, create another credential with the new key and revoke the previous one. Rotating the API secret does not change the public key.

Accepted format

The public key is registered in PEM (X.509 SubjectPublicKeyInfo), with the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- lines. The algorithm in the signature alg has to work with the key type:

algKey typeNote
rsa-v1_5-sha256RSA, 2048 bits or moreRecommended. It is SHA256withRSA in Java and RSA-SHA256 in Node.js
rsa-pss-sha512RSAPSS with a salt of 64 bytes
ecdsa-p256-sha256EC, P-256 curveThe signature is r + s, 64 bytes long, not DER
ed25519Ed25519

Every example on this page uses rsa-v1_5-sha256. The playground card only accepts RSA keys.

Generating the pair

First, generate the private key. Keep the file in a vault and do not share it with anyone.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private-key.pem

Then generate the public key from it. The content of public-key.pem is what you register on the credential.

openssl rsa -in private-key.pem -pubout -out public-key.pem

openssl genrsa also produces a valid key, but the file format depends on the version. The openssl that ships with macOS (LibreSSL) writes PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) and OpenSSL 3 writes PKCS#8 (-----BEGIN PRIVATE KEY-----). Both sign the same way and derive the same public key, but some libraries only accept PKCS#8. The genpkey command above writes PKCS#8 on any version. To convert a key you already have: openssl pkcs8 -topk8 -nocrypt -in private-key.pem -out private-key-pkcs8.pem.

Handle the private key with the same care as the API secret. Keep it in a vault (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) and load it at runtime. Do not commit it, log it, or paste it into forms.


Registering the public key

Registration happens in the credentials panel in Internet Banking, while creating the credential.

Name the credential and pick the environment

Sandbox or Production. Each environment has its own credentials and keys.

Select the scopes

There is one scope per route, grouped by resource. The credential only reaches the routes of the scopes it was granted, so grant only what the integration uses.

Paste the public key

The field becomes mandatory when any selected scope requires signed requests. Paste the full content of public-key.pem, including the BEGIN and END lines.

Confirm and store the API key / API secret pair

The API secret can be viewed again in the panel later, but it is best to copy it straight into your vault the first time it is shown.

If you paste a private key by mistake, the browser rejects it before sending and shows the message "This content is a private key". In that case, paste the file produced by the command with -pubout.


When a signature is required

When a credential has a public key registered, every call made with it must be signed, reads included. Without a signature the API answers 401, even for a simple balance GET. The only call that is never signed is token issuance (POST /oauth/v1/access-token), which uses Basic auth.

Some scopes are sensitive and can only be granted to a credential with a public key. They are the write scopes, which change company data, and the read scopes that expose sensitive data. This is the full list:

ResourceOperationRoute
AttachmentsCreate attachmentPOST /attachments/v1/attachments
CategoriesCreate categoryPOST /categories/v1/categories
CategoriesUpdate categoryPATCH /categories/v1/categories/{id}
CategoriesDelete categoriesDELETE /categories/v1/categories
Cost centersCreate cost centerPOST /cost-centers/v1/cost-centers
Cost centersUpdate cost centerPATCH /cost-centers/v1/cost-centers/{id}
Cost centersDelete cost centerDELETE /cost-centers/v1/cost-centers/{id}
CardsBlock cardPOST /credit-cards/v1/{id}/block
CardsUnblock cardPOST /credit-cards/v1/{id}/unblock
VCNCreate VCNPOST /credit-cards/v1/vcns
VCNUpdate VCNPATCH /credit-cards/v1/vcns/{vcnId}
VCNReveal card dataGET /credit-cards/v1/vcns/{vcnId}/reveal
TransactionsUpdate banking transactionPATCH /statements/v1/banking/{transactionId}
TransactionsUpdate card transactionPATCH /statements/v1/credit-card/{transactionId}
SuppliersCreate supplierPOST /suppliers/v1/suppliers
SuppliersUpdate supplierPUT /suppliers/v1/suppliers/{id}
SuppliersDelete supplierDELETE /suppliers/v1/suppliers/{id}
UsersCreate invitePOST /users/v1/invites
UsersUpdate userPATCH /users/v1/users/{userId}
UsersDelete userDELETE /users/v1/users/{userId}

A credential with none of these scopes can be created without a public key, and then none of its calls are signed. If the credential has any sensitive scope, all of its calls are signed.


How to sign

Signing follows RFC 9421 (HTTP Message Signatures). A signed request carries these headers:

Content-Digest: sha-256=:rNFh5H4dKqPKimxTAAh5HGIXB7cKBUuisVyMk/LC4eA=:
Signature-Input: sig=("@method" "@path" "@query" "content-digest");created=1757337600;alg="rsa-v1_5-sha256";nonce="3f0c9a4e-7b1d-4c8e-9f2a-5d6b8c1e0a47"
Signature: sig=:<base64 signature>:
HeaderContent
Content-DigestSHA-256 hash of the body, in base64 and between :, as defined in RFC 9530. Only sent when the request has a body
Signature-InputWhat was signed and the signature parameters, under a label (sig)
SignatureThe signature in base64, between :, under the same label

Each request carries a single signature. If Signature-Input has more than one label, the API rejects it.

Components and parameters

ComponentValueWhen
"@method"Method in uppercase, for example POSTAlways
"@path"Path without the host or the query, for example /statements/v1/bankingAlways
"@query"Query with its leading ?, for example ?startDate=2025-01-01. With no query, it is just ?Always
"content-digest"The same value sent in the Content-Digest headerWhen there is a body
ParameterUse
createdRequired. Unix time in seconds. The API accepts up to 300 seconds of difference from its clock
algRequired. Has to work with the registered key (see Accepted format)
nonceOptional, but recommended. Use a different value on every request, such as a UUID
expiresOptional. The signature is rejected after this time
keyidOptional. You do not need to send it

The signature base

The signed text is the RFC 9421 signature base. It has one line for each component, in the same order as Signature-Input, and ends with the "@signature-params" line. Lines are separated by and there is no line break at the end. For the POST /suppliers/v1/suppliers example above, the base looks like this:

"@method": POST
"@path": /suppliers/v1/suppliers
"@query": ?
"content-digest": sha-256=:rNFh5H4dKqPKimxTAAh5HGIXB7cKBUuisVyMk/LC4eA=:
"@signature-params": ("@method" "@path" "@query" "content-digest");created=1757337600;alg="rsa-v1_5-sha256";nonce="3f0c9a4e-7b1d-4c8e-9f2a-5d6b8c1e0a47"

The signature is computed with the alg algorithm over this base in UTF-8. The most common mistakes when building the base are below.

What the signature covers

The signature covers the method, the path, the query, the body (through Content-Digest), and the time it was created. So an intercepted signature cannot be used on a different request.

Send a different nonce on every request. And do not write the Signature-Input and Signature headers to logs, proxies, or APM tools, the same way you already keep the token out of them.


Examples

Node.js

import { createHash, createSign, randomUUID } from "node:crypto";

// The private key comes from your vault at 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 = "my-app/1.0";

type SignedRequest = {
  method: string;
  target: string; // path with the query, already encoded, as it goes in the URL
  token: string;
  payload?: unknown;
};

export async function signedRequest({
  method,
  target,
  token,
  payload
}: SignedRequest): Promise<Response> {
  // Serialize only once. The digest and the request use the same 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)
    },
    // With no body, send undefined. Sending "" changes what the server receives.
    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;
}

Usage:

// Write with a body.
await signedRequest({
  method: "POST",
  target: "/suppliers/v1/suppliers",
  token: accessToken,
  payload: { name: "Example Supplier", document: "00000000000191" }
});

// Reads are signed too. The query is built once and used in the signed path.
const query = new URLSearchParams({ startDate: "2025-01-01", endDate: "2025-01-31" });

await signedRequest({
  method: "GET",
  target: `/statements/v1/banking?${query}`,
  token: accessToken
});

If the key is in an environment variable with written in place of the line breaks, convert it before use with PRIVATE_KEY.replace(/\ /g, " "). Without the line breaks, the PEM does not load.

Shell (openssl)

TOKEN="${CS_ACCESS_TOKEN}"
BASE_URL="https://api-sandbox.contasimples.com"
METHOD="POST"
TARGET_PATH="/suppliers/v1/suppliers"
QUERY="?"
BODY='{"name":"Example Supplier","document":"00000000000191"}'
CREATED=$(date +%s)
NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')

# printf and not echo, because echo appends 
 and the digest does not match.
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}""

# -A keeps the base64 on one line. With line breaks, the header is rejected before it reaches the 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 and not -d, because -d removes the line breaks from the body.
curl -X "$METHOD" "${BASE_URL}${TARGET_PATH}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -H "User-Agent: my-app/1.0" \
  -H "Content-Digest: ${DIGEST}" \
  -H "Signature-Input: sig=${PARAMS}" \
  -H "Signature: sig=:${SIGNATURE}:" \
  --data-binary "$BODY"

On a read with a query, the query is signed the way it goes in the URL and there is no 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: my-app/1.0" \
  -H "Signature-Input: sig=${PARAMS}" \
  -H "Signature: sig=:${SIGNATURE}:"

Testing from the API Reference playground

The Try it button on each endpoint makes a real call to the API, so it needs a signature when the credential has a public key. Inside Try it, right below Headers, there is the Request signing card. The card does not show up on the token issuance page, because that call is not signed.

Paste the private key of your Sandbox credential

The card accepts RSA keys in PKCS#8 (BEGIN PRIVATE KEY) or PKCS#1 (BEGIN RSA PRIVATE KEY).

Check the fingerprint

The card shows an identifier of 8 characters for the key and the matching public key. Check that it is the same one registered on the credential.

Send as usual

On every send, the card computes Signature-Input, Signature, and, if there is a body, Content-Digest for that call. The headers from the last send show up in the card, with a button to copy them.

Use only a Sandbox key. Never paste the private key of your production integration into a web page, this one included. The playground sends the request through the documentation portal's server, and the token and the body go through it.

The key stays only in the tab's memory and is not saved anywhere. If you reload the page, you have to paste it again. You can also test without pasting the key into the card. The Signature-Input, Signature, and Content-Digest fields are in the form, and you can fill them with values produced by the examples above. The signature has to be made over the same route and query as the form, and the request has to go out within 300 seconds.


Verifying your signature before calling the API

When a call is rejected, verify the signature on your machine with the public key you registered. Using the variables from the shell example, write the base to a file and run the check. If it fails here, the problem is in how the signature was made.

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 means the signed base is not the one you just built. Usually there is an extra line break, a "@signature-params" line that differs from Signature-Input, a query built a different way, or a body serialized twice.


Errors


Best practices


Next steps