Skip to main content

Sign every request

Every call to /v2/* carries two headers:

Authorization: DPoP <access_token>
DPoP: <jwt>

The DPoP value is an ES256 JWT signed with your private key, new for every request:

header:  { "typ": "dpop+jwt", "alg": "ES256", "jwk": { ...your public key... } }
payload: { "htm": "POST",
"htu": "https://api.sdb.lbpay.com.br/v2/pix/cash-out",
"iat": <now>,
"jti": "<uuid>",
"ath": "<base64url(sha256(access_token))>",
"qh": "<base64url(sha256(canonical query))>",
"bh": "<base64url(sha256(exact body sent))>" }

The three details that bite​

htu carries no query and no fragment. It is scheme://host/path and nothing else, as RFC 9449 requires. The query goes separately, in qh.

qh is the hash of the canonical query: split on &, drop empty segments, sort the segments byte-wise, join again with &, without percent-decoding anything. With no query, it is the hash of the empty string.

const canonicalQuery = (search) =>
search.replace(/^\?/, '').split('&').filter(Boolean).sort().join('&');

// "?b=2&a=1" -> "a=1&b=2"
// "?tags=x&tags=y" -> "tags=x&tags=y"
// "" -> ""

bh is the hash of the body byte for byte, exactly as you send it. For a GET with no body it is the hash of the empty string. bh is never optional.

Serialize once

Hash the same bytes you put on the wire. Serializing your object twice (once to hash, once to send) gives a different byte order the day a key moves, and the request is rejected with DPOP_BODY_MISMATCH.

Headers on every API call​

HeaderContent
AuthorizationThe word DPoP, a space, and the access token
DPoPA fresh proof, signed for this request
Content-Typeapplication/json on calls with a body
Content-EncodingAbsent, or identity. Compressed is rejected with 415

Compression is refused on /v2 because it would create two representations of the same body, and the signature must cover exactly one.

The body is whatever the endpoint asks for, in JSON. There is no envelope of ours around it. The only rule is that the bytes you send are exactly the ones you hashed in bh.

Complete example​

Continues from the token step, in the same file and with the same helpers.

// ── 2. chamada assinada ─────────────────────────────────────────────────────────
async function call(accessToken, method, path, payload) {
const url = `${BASE}${path}`
// Serialize UMA vez: é esta string que será hasheada E enviada.
const body = payload === undefined ? '' : JSON.stringify(payload)

const proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk },
{
htm: method,
htu: url,
iat: now(),
jti: randomUUID(),
ath: sha(accessToken),
qh: sha(''),
bh: sha(body),
},
)

return fetch(url, {
method,
headers: {
authorization: `DPoP ${accessToken}`,
dpop: proof,
...(body ? { 'content-type': 'application/json' } : {}),
},
...(body ? { body } : {}),
})
}

const token = await getToken()
const res = await call(token, 'POST', '/v2/pix/cash-out', { amount: 5000, to: 'pix@alice' })
console.log(res.status, await res.text())
Check your canonicalization

For ?b=2&a=1&&c=%2F, the canonical query is a=1&b=2&c=%2F: sorted, empty segment dropped, %2F left encoded. Its hash is vOuB6Bjc7yvDlU5r2UPhEMFfwYfW92GA22LyuE9bM4Q, and the hash of an empty body or empty query is always 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU. If your implementation produces those two values, it agrees with ours.

Clock​

A drift larger than 60 seconds is rejected (DPOP_STALE). Use NTP. This is the single most common cause of an integration that works on a laptop and fails on a container host.

Signature encoding​

ECDSA signatures come in two encodings: DER and raw R‖S of 64 bytes. We accept both. Use whatever your library emits, without converting anything.

That includes keys in KMS or Cloud HSM: their Sign returns DER, and it can be sent as is.

What we verify, in order​

  1. The token signature, and that it was issued by us.
  2. The credential envelope inside the token: the account, profile and key it was signed for.
  3. That the token's claims match the envelope.
  4. The profile against the route you are calling.
  5. The DPoP proof: signature, jti (single use), htm, htu, ath, qh, bh, and iat window.
  6. Revocation.
  7. Your source IP against the credential's allowed list.

Steps 1 to 4 are pure verification of what the token carries, with no lookup. Steps 5 to 7 run on every single request, which is why revoking a credential takes effect in about a second even though the token is self-contained.