Skip to main content

Get a token

POST https://api.sdb.lbpay.com.br/v2/oauth/token

The body is application/x-www-form-urlencoded with three fields, plus a DPoP header. It is not JSON.

grant_type=client_credentials
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<jwt>
DPoP: <jwt>

Where each key comes in​

The private key never leaves your infrastructure. It only signs: the client_assertion and every DPoP proof. It never goes in a header, in a body, or to us.

The public key you register once in the portal, and it also travels in every request, inside the DPoP header, in the jwk field. That is required by RFC 9449 and is not a leak: public is public. It lets us verify the proof, and we check that its thumbprint equals the cnf.jkt recorded in the token at issuance. Signing with a different key gives 401.

You do not need to keep a separate file with the public key in JWK form. It is derived from the private key in one line (createPublicKey(privateKey).export({ format: 'jwk' }) in Node).

The client assertion​

A JWT signed ES256 with your private key:

header:  { "alg": "ES256", "kid": "<kid>" }
payload: { "iss": "<client_id>",
"sub": "<client_id>",
"aud": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <now>,
"exp": <now + 60>,
"jti": "<uuid>" }

aud must be the token endpoint, byte for byte. An assertion minted for another environment is rejected.

The DPoP proof for the token endpoint​

Same shape as the per-request proof, without ath, qh and bh, because there is no token yet and no body to bind:

header:  { "typ": "dpop+jwt", "alg": "ES256", "jwk": { ...your public key... } }
payload: { "htm": "POST",
"htu": "https://api.sdb.lbpay.com.br/v2/oauth/token",
"iat": <now>,
"jti": "<uuid>" }

Complete example​

// Fluxo completo: obtém o token e faz uma chamada assinada. Só biblioteca padrão.
import { createHash, createPrivateKey, createPublicKey, createSign, randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'

const BASE = process.env.LBPAY_BASE ?? 'https://seamless-v2.sdb.lbpay.com.br'
const CLIENT_ID = process.env.LBPAY_CLIENT_ID ?? 'cli-demo'
const KID = process.env.LBPAY_KID ?? 'kid-demo'

const privateKey = createPrivateKey(readFileSync('private.pem', 'utf8'))
// A pública sai da privada: você não precisa manter um segundo arquivo.
const publicJwk = createPublicKey(privateKey).export({ format: 'jwk' })

const b64u = (b) => Buffer.from(b).toString('base64url')
const sha = (s) => b64u(createHash('sha256').update(s).digest())
const now = () => Math.floor(Date.now() / 1000)

function jws(header, payload) {
const h = b64u(JSON.stringify(header))
const p = b64u(JSON.stringify(payload))
// A saída default do Node (DER) é aceita; R‖S também. Use o que sua lib emitir.
const signature = createSign('sha256').update(`${h}.${p}`).sign(privateKey)
return `${h}.${p}.${b64u(signature)}`
}

// ── 1. token ───────────────────────────────────────────────────────────────────────
async function getToken() {
const url = `${BASE}/oauth/token`
const iat = now()

const clientAssertion = jws(
{ alg: 'ES256', kid: KID },
{ iss: CLIENT_ID, sub: CLIENT_ID, aud: url, iat, exp: iat + 60, jti: randomUUID() },
)
// Versão curta: aqui ainda não há token, query nem corpo para amarrar.
const proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk },
{ htm: 'POST', htu: url, iat, jti: randomUUID() },
)

const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: proof },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
}),
})
if (!res.ok) throw new Error(`token ${res.status}: ${await res.text()}`)
return (await res.json()).access_token
}

Every tab is executed against a server that verifies both proofs exactly as production does: signature, typ, htu, ath, bh, the iat window, and that no private component leaks into the jwk. Documentation nobody ran is documentation that does not work.

Lifetime and renewal​

The token is valid for at most 60 seconds, for any profile. Renew between 35 and 45 seconds, one renewal at a time (singleflight). Do not fire a renewal per request.

let cached = { token: null, exp: 0, inflight: null };

async function token() {
const now = Math.floor(Date.now() / 1000);
if (cached.token && now < cached.exp - 20) return cached.token;
cached.inflight ??= requestToken().then((t) => {
cached = { token: t.access_token, exp: now + t.expires_in, inflight: null };
return cached.token;
});
return cached.inflight;
}
Why 60 seconds

The token carries the whole signed credential, so the resource server needs no database lookup on the hot path. Sixty seconds is what keeps a revocation from lingering: after that, the token is gone no matter what.

One-shot jti​

Each jti is single use, on both the assertion and the proof. Reusing one gives API_CREDENTIAL_PROOF_REPLAY, so generate a fresh UUID per proof.