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.
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
| Header | Content |
|---|---|
Authorization | The word DPoP, a space, and the access token |
DPoP | A fresh proof, signed for this request |
Content-Type | application/json on calls with a body |
Content-Encoding | Absent, 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.
- Node
- Python
- PHP
- Java
- C#
- Go
- Ruby
- Elixir
- Clojure
// ── 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())
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = f"{BASE}/v2/pix/cash-out"
# Serialize UMA vez: e esta string que sera hasheada E enviada.
body = json.dumps({"amount": 5000, "to": "pix@alice"}, separators=(",", ":"))
proof = jws(
{"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk},
{"htm": "POST", "htu": url, "iat": int(time.time()), "jti": str(uuid.uuid4()),
"ath": sha(access_token), "qh": sha(""), "bh": sha(body)},
)
status, resp = post(
url,
{"Authorization": f"DPoP {access_token}", "DPoP": proof,
"Content-Type": "application/json"},
body.encode(),
)
print(status, resp)
raise SystemExit(0 if status == 200 else 1)
// ── 2. chamada assinada ───────────────────────────────────────────────────────
$url = "$BASE/v2/pix/cash-out";
// Serialize UMA vez: é esta string que será hasheada E enviada.
$body = json_encode(['amount' => 5000, 'to' => 'pix@alice']);
$proof = jws(
['typ' => 'dpop+jwt', 'alg' => 'ES256', 'jwk' => $publicJwk],
['htm' => 'POST', 'htu' => $url, 'iat' => time(),
'jti' => bin2hex(random_bytes(16)), 'ath' => sha($accessToken),
'qh' => sha(''), 'bh' => sha($body)],
);
[$status, $resp] = post($url, [
"Authorization: DPoP $accessToken",
"DPoP: $proof",
'Content-Type: application/json',
], $body);
echo "$status $resp\n";
exit($status === 200 ? 0 : 1);
// ── 2. chamada assinada ───────────────────────────────────────────────
String url = base + "/v2/pix/cash-out";
// Serialize UMA vez: e esta string que sera hasheada E enviada.
String body = "{\"amount\":5000,\"to\":\"pix@alice\"}";
String proof = jws(
"{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" + publicJwk + "}",
String.format("{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\","
+ "\"ath\":\"%s\",\"qh\":\"%s\",\"bh\":\"%s\"}",
url, System.currentTimeMillis() / 1000, UUID.randomUUID(),
sha(accessToken), sha(""), sha(body)));
HttpResponse<String> res = post(url, body,
"Authorization", "DPoP " + accessToken,
"DPoP", proof,
"Content-Type", "application/json");
System.out.println(res.statusCode() + " " + res.body());
System.exit(res.statusCode() == 200 ? 0 : 1);
}
}
// ── 2. chamada assinada ───────────────────────────────────────────────────────
var url = $"{BASE}/v2/pix/cash-out";
// Serialize UMA vez: e esta string que sera hasheada E enviada.
var body = "{\"amount\":5000,\"to\":\"pix@alice\"}";
var proof = Jws(
new Dictionary<string, object> { ["typ"] = "dpop+jwt", ["alg"] = "ES256", ["jwk"] = publicJwk },
new Dictionary<string, object>
{
["htm"] = "POST", ["htu"] = url,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["jti"] = Guid.NewGuid().ToString(),
["ath"] = Sha(accessToken), ["qh"] = Sha(""), ["bh"] = Sha(body),
});
var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Add("Authorization", $"DPoP {accessToken}");
req.Headers.Add("DPoP", proof);
var res = await http.SendAsync(req);
Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
return res.IsSuccessStatusCode ? 0 : 1;
// ── 2. chamada assinada ───────────────────────────────────────────────────
target := base + "/v2/pix/cash-out"
// Serialize UMA vez: é esta string que será hasheada E enviada.
body := mustJSON(map[string]any{"amount": 5000, "to": "pix@alice"})
proof := jws(
map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": publicJWK},
map[string]any{"htm": "POST", "htu": target, "iat": time.Now().Unix(),
"jti": jti(), "ath": sha(token.AccessToken), "qh": sha(""), "bh": sha(string(body))},
)
status, resp = post(target, map[string]string{
"Authorization": "DPoP " + token.AccessToken,
"DPoP": proof,
"Content-Type": "application/json",
}, body)
fmt.Println(status, resp)
if status != 200 {
os.Exit(1)
}
}
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = "#{BASE}/v2/pix/cash-out"
# Serialize UMA vez: é esta string que será hasheada E enviada.
body = JSON.generate(amount: 5000, to: 'pix@alice')
proof = jws(
{ typ: 'dpop+jwt', alg: 'ES256', jwk: PUBLIC_JWK },
{ htm: 'POST', htu: url, iat: Time.now.to_i, jti: SecureRandom.uuid,
ath: sha(access_token), qh: sha(''), bh: sha(body) }
)
status, resp = post(
url,
{ 'Authorization' => "DPoP #{access_token}", 'DPoP' => proof,
'Content-Type' => 'application/json' },
body
)
puts "#{status} #{resp}"
exit(status == 200 ? 0 : 1)
# ── 2. chamada assinada ───────────────────────────────────────────────────────
url = "#{base}/v2/pix/cash-out"
# Serialize UMA vez: é esta string que será hasheada E enviada.
body = JSON.encode!(%{"amount" => 5000, "to" => "pix@alice"})
proof =
LBPay.jws(key, %{"typ" => "dpop+jwt", "alg" => "ES256", "jwk" => public_jwk}, %{
"htm" => "POST", "htu" => url, "iat" => System.system_time(:second),
"jti" => LBPay.jti(), "ath" => LBPay.sha(access_token),
"qh" => LBPay.sha(""), "bh" => LBPay.sha(body)
})
{status, resp} =
LBPay.post(url,
[{~c"Authorization", String.to_charlist("DPoP #{access_token}")},
{~c"DPoP", String.to_charlist(proof)}],
"application/json", body)
IO.puts("#{status} #{resp}")
System.halt(if status == 200, do: 0, else: 1)
;; ── 2. chamada assinada ───────────────────────────────────────────────────────
(let [access-token (second (re-find #"\"access_token\"\s*:\s*\"([^\"]+)\"" resp))
url (str base "/v2/pix/cash-out")
;; Serialize UMA vez: e esta string que sera hasheada E enviada.
body "{\"amount\":5000,\"to\":\"pix@alice\"}"
proof (jws (str "{\"typ\":\"dpop+jwt\",\"alg\":\"ES256\",\"jwk\":" public-jwk "}")
(format (str "{\"htm\":\"POST\",\"htu\":\"%s\",\"iat\":%d,\"jti\":\"%s\","
"\"ath\":\"%s\",\"qh\":\"%s\",\"bh\":\"%s\"}")
url (quot (System/currentTimeMillis) 1000) (UUID/randomUUID)
(sha access-token) (sha "") (sha body)))
[status resp] (post url body
[["Authorization" (str "DPoP " access-token)]
["DPoP" proof]
["Content-Type" "application/json"]])]
(println status resp)
(System/exit (if (= status 200) 0 1))))
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
- The token signature, and that it was issued by us.
- The credential envelope inside the token: the account, profile and key it was signed for.
- That the token's claims match the envelope.
- The profile against the route you are calling.
- The DPoP proof: signature,
jti(single use),htm,htu,ath,qh,bh, andiatwindow. - Revocation.
- 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.