Pular para o conteúdo principal

Autenticar na API Pix (token + mTLS)

Resposta rápida: Chame o endpoint de token com grant_type=client_credentials e client_id/client_secret no corpo da requisição — não em Basic auth — sem enviar scope, apresentando certificado, chave e a CA do banco; use o access_token como Bearer nas próximas chamadas.

Pré-requisitos​

  • Credenciais
  • Conceitos: autenticação, mTLS
  • Placeholders: {{TOKEN_URL}}, {{YOUR_CLIENT_ID}}, {{YOUR_CLIENT_SECRET}}, {{PATH_TO_CERT}}, {{PATH_TO_KEY}}, {{PATH_TO_CA}}

Três regras medidas em produção​

O servidor de autorização é um Keycloak, e o comportamento dele surpreende quem chega pelo padrão BACEN:

  1. Credenciais vão no body. Basic client_id:client_secret responde 401. Mande client_id e client_secret como campos de formulário, junto do grant_type.
  2. Não envie scope. Conta sem escopos atribuídos responde 401 invalid_scope para qualquer escopo pedido — inclusive os que a documentação BACEN cita (cob.read, cob.write, ...). Cliente com escopo hardcoded nunca tira um token.
  3. A CA não é pública. O certificado do servidor é assinado por uma autoridade privada, então sem passar a CA o handshake falha antes de qualquer HTTP. Veja mTLS para extraí-la do .pfx.

Passo a passo​

  1. Substitua placeholders nos snippets abaixo (não commite o arquivo preenchido).
  2. Execute o snippet da sua stack.
  3. Confirme JSON com access_token e expires_in. A resposta traz também not-before-policy e refresh_expires_in — sinais de que é Keycloak.
  4. Armazene o token em memória/redis com TTL menor que expires_in.

Código​

Fontes canônicas em snippets/:

  • snippets/curl/token.sh
  • snippets/node/token.mjs
  • snippets/python/token.py
  • snippets/php/token.php

curl​

# source: snippets/curl/token.sh
curl -sS -X POST '{{TOKEN_URL}}' \
--cert '{{PATH_TO_CERT}}' \
--key '{{PATH_TO_KEY}}' \
--cacert '{{PATH_TO_CA}}' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id={{YOUR_CLIENT_ID}}' \
-d 'client_secret={{YOUR_CLIENT_SECRET}}'

Node.js​

// source: snippets/node/token.mjs
import fs from 'node:fs';
import https from 'node:https';
import { URL } from 'node:url';

const url = new URL('{{TOKEN_URL}}');
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: '{{YOUR_CLIENT_ID}}',
client_secret: '{{YOUR_CLIENT_SECRET}}',
}).toString();

const req = https.request(
{
method: 'POST',
hostname: url.hostname,
path: url.pathname + url.search,
port: url.port || 443,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
cert: fs.readFileSync('{{PATH_TO_CERT}}'),
key: fs.readFileSync('{{PATH_TO_KEY}}'),
ca: fs.readFileSync('{{PATH_TO_CA}}'),
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => console.log(res.statusCode, data));
},
);
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(body);
req.end();

Python​

# source: snippets/python/token.py
import requests

resp = requests.post(
"{{TOKEN_URL}}",
data={
"grant_type": "client_credentials",
"client_id": "{{YOUR_CLIENT_ID}}",
"client_secret": "{{YOUR_CLIENT_SECRET}}",
},
cert=("{{PATH_TO_CERT}}", "{{PATH_TO_KEY}}"),
verify="{{PATH_TO_CA}}",
timeout=30,
)
print(resp.status_code, resp.text)

PHP​

// source: snippets/php/token.php
$ch = curl_init('{{TOKEN_URL}}');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => '{{YOUR_CLIENT_ID}}',
'client_secret' => '{{YOUR_CLIENT_SECRET}}',
]),
CURLOPT_SSLCERT => '{{PATH_TO_CERT}}',
CURLOPT_SSLKEY => '{{PATH_TO_KEY}}',
CURLOPT_CAINFO => '{{PATH_TO_CA}}',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, curl_error($ch) . PHP_EOL);
exit(1);
}
echo $body, PHP_EOL;

Se der errado​

SintomaCausaAção
401 no token com secret corretoCredenciais em Basic authMandar client_id/client_secret no body
401 invalid_scopeCliente pedindo scopeRemover o scope da requisição
TLS handshake error / unable to get local issuerCA do banco ausentePassar --cacert / ca / verify / CURLOPT_CAINFO
TLS handshake error com CA presenteCert ou key errados, ou expiradosConferir paths, formato e validade
403 depois, já com BearerProduto não contratado na contaerros comuns + suporte

Diagnóstico barato antes de culpar a credencial:

openssl s_client -connect <host-da-api>:443 -CAfile '{{PATH_TO_CA}}' </dev/null 2>&1 | grep 'Verify return'

Verify return code: 0 significa que a cadeia está certa — daí em diante o problema é HTTP, não TLS.

Checklist​

  • Token obtido em HML
  • Credenciais no body, não em Basic
  • Nenhum scope na requisição
  • CA do banco no cliente HTTP
  • Secret não está no git

Próximo passo​

→ 03 — Criar cobrança imediata

FAQ​

P: O token vale para sempre?​

R: Não. Renove com base em expires_in.

P: A documentação BACEN cita escopos. Por que não mandar?​

R: Os escopos são atribuídos à credencial no banco. Pedir um escopo que a conta não tem derruba a emissão inteira do token — e uma conta sem escopo algum recusa qualquer valor de scope.