Autenticar na API Pix (token + mTLS)
Resposta rápida: Chame o endpoint de token com
grant_type=client_credentialseclient_id/client_secretno corpo da requisição — não em Basic auth — sem enviarscope, apresentando certificado, chave e a CA do banco; use oaccess_tokencomo 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:
- Credenciais vão no body.
Basic client_id:client_secretresponde 401. Mandeclient_ideclient_secretcomo campos de formulário, junto dogrant_type. - Não envie
scope. Conta sem escopos atribuídos responde 401invalid_scopepara qualquer escopo pedido — inclusive os que a documentação BACEN cita (cob.read,cob.write, ...). Cliente com escopo hardcoded nunca tira um token. - 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
- Substitua placeholders nos snippets abaixo (não commite o arquivo preenchido).
- Execute o snippet da sua stack.
- Confirme JSON com
access_tokeneexpires_in. A resposta traz tambémnot-before-policyerefresh_expires_in— sinais de que é Keycloak. - Armazene o token em memória/redis com TTL menor que
expires_in.
Código
Fontes canônicas em snippets/:
snippets/curl/token.shsnippets/node/token.mjssnippets/python/token.pysnippets/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
| Sintoma | Causa | Ação |
|---|---|---|
| 401 no token com secret correto | Credenciais em Basic auth | Mandar client_id/client_secret no body |
401 invalid_scope | Cliente pedindo scope | Remover o scope da requisição |
TLS handshake error / unable to get local issuer | CA do banco ausente | Passar --cacert / ca / verify / CURLOPT_CAINFO |
| TLS handshake error com CA presente | Cert ou key errados, ou expirados | Conferir paths, formato e validade |
| 403 depois, já com Bearer | Produto não contratado na conta | erros 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
scopena 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.