How the HMAC-SHA256 signature in x-paag-webhook-signature works, and how to verify it.
Your webhook endpoint is a public URL. Anyone who learns it can POST to it, and nothing in an HTTP request proves who sent it — a forged call announcing that a batch "completed" looks exactly like a real one.
Every delivery carries an x-paag-webhook-signature header. It is the only thing that separates us from anyone else calling your endpoint. Check it before you act on the body.
Same scheme as the Payments API. Header name, algorithm and encoding are identical, so if you already verify Paag payment webhooks you can reuse that code as-is. Only the secret differs — each product has its own.
The idea in one paragraph
At registration we gave you a signing secret — a random string only you and we know. Before sending a webhook, we run the exact bytes of the request body through HMAC-SHA256 keyed with that secret, and put the result in a header. You repeat the calculation on the bytes you received. If the results match, the message came from someone holding the secret, and nobody altered it in transit. If they differ, throw the request away.
This is not encryption. The body is not secret — it is signed. Anyone can read it; only someone with the secret can produce a matching signature for it.
The algorithm
assinatura = base64( hex( HMAC-SHA256( chave = segredo, mensagem = corpo_cru ) ) )Read that in order — there are two encodings, and the order matters:
- Compute the HMAC-SHA256 of the raw body with your secret. That is 32 bytes.
- Render those bytes as a lowercase hexadecimal string — 64 characters.
- Base64-encode that string, not the original bytes. Result: 88 characters.
The most likely mistake is Base64-encoding the digest bytes directly, which is what most tutorials show. That produces a 44-character value and never matches. If your result is 44 characters long, you skipped step 2.
- chave: your signing secret, as UTF-8 bytes, exactly as we gave it to you — no trimming, no decoding.
- mensagem: the raw bytes of the request body, exactly as they arrived.
- The header carries the Base64 value alone. There is no
sha256=prefix.
Test vector
Check your implementation against this before going live. These are real values, produced by the same code that signs production deliveries.
Secret
kGx7Qm2vZs9tR4wLpN1eYb6UaHc3JdFiOo0AzXyKlM8Body — one line, 191 bytes, no trailing newline
{"event": "batch.completed", "batch_id": "lote_3c6e0b8a9c15", "status": "completed", "total_requested": 1000, "total_found": 999, "total_not_found": 1, "completed_at": "2026-08-14T16:05:34Z"}Expected signature
ODdmYTE0MzViMGY0ODZlYjdlMTc4N2FmMjc3YTk2NjllN2E0ZjAxZjg5NTk3ZjlmOTQ3MTAyOTg0YzA1ODdlNA==Base64-decoding it gives back the hexadecimal digest, which is a quick way to see whether your pipeline is right:
87fa1435b0f486eb7e1787af277a9669e7a4f01f89597f9f947102984c0587e4If your code produces something else, one of the three mistakes below is why. All the snippets on this page were run against this vector and reproduce it.
Implementations
Each example takes the raw body and the header value and returns whether the delivery is genuine.
Shell
For a one-off check from the terminal, with the body saved to a file exactly as received:
export SEGREDO='kGx7Qm2vZs9tR4wLpN1eYb6UaHc3JdFiOo0AzXyKlM8'
openssl dgst -sha256 -hmac "$SEGREDO" -hex corpo.json \
| awk '{print $NF}' | tr -d '\n' | base64
# ODdmYTE0MzViMGY0ODZlYjdlMTc4N2FmMjc3YTk2NjllN2E0ZjAxZjg5NTk3ZjlmOTQ3MTAyOTg0YzA1ODdlNA==Handy for debugging, not for production: the secret lands in the process argument list, where anyone running ps can read it.
Python
import base64
import hashlib
import hmac
def assinatura_valida(corpo: bytes, header: str, segredo: str) -> bool:
digest = hmac.new(segredo.encode(), corpo, hashlib.sha256).hexdigest()
esperado = base64.b64encode(digest.encode()).decode()
return hmac.compare_digest(header or "", esperado)With FastAPI:
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/kyg")
async def receber(request: Request, x_paag_webhook_signature: str = Header(None)):
corpo = await request.body() # bytes crus, antes de qualquer parse
if not assinatura_valida(corpo, x_paag_webhook_signature, SEGREDO):
raise HTTPException(status_code=401, detail="assinatura invalida")
evento = await request.json()
enfileirar(evento["batch_id"]) # responde rapido; processa depois
return {"received": True}Node
const crypto = require('crypto');
function assinaturaValida(corpo, header, segredo) {
const hex = crypto.createHmac('sha256', segredo).update(corpo).digest('hex');
const esperado = Buffer.from(hex).toString('base64');
const a = Buffer.from(header || '', 'utf8');
const b = Buffer.from(esperado, 'utf8');
// timingSafeEqual exige o mesmo tamanho: compare antes, sem sair cedo.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}With Express, the raw body must be kept — express.json() discards it:
app.post('/webhooks/kyg',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!assinaturaValida(req.body, req.get('x-paag-webhook-signature'), SEGREDO)) {
return res.status(401).send('assinatura invalida');
}
const evento = JSON.parse(req.body.toString('utf8'));
enfileirar(evento.batch_id);
res.json({ received: true });
});PHP
hash_hmac already returns hexadecimal, so it feeds straight into base64_encode:
function assinatura_valida(string $corpo, ?string $header, string $segredo): bool {
$esperado = base64_encode(hash_hmac('sha256', $corpo, $segredo));
return hash_equals($esperado, $header ?? '');
}
$corpo = file_get_contents('php://input'); // corpo cru
$header = $_SERVER['HTTP_X_PAAG_WEBHOOK_SIGNATURE'] ?? '';
if (!assinatura_valida($corpo, $header, $segredo)) {
http_response_code(401);
exit;
}Ruby
require 'base64'
require 'openssl'
def assinatura_valida?(corpo, header, segredo)
hex = OpenSSL::HMAC.hexdigest('SHA256', segredo, corpo)
esperado = Base64.strict_encode64(hex)
recebido = header.to_s
return false unless esperado.bytesize == recebido.bytesize
esperado.bytes.zip(recebido.bytes).inject(0) { |acc, (a, b)| acc | (a ^ b) }.zero?
endThe comparison is spelled out because OpenSSL.secure_compare only exists in the openssl gem 2.2 and later — on Ruby 2.6 it raises NoMethodError. In a Rails or Rack app you can use Rack::Utils.secure_compare(esperado, recebido) instead.
Use strict_encode64, not encode64: the latter inserts a line break every 60 characters and the comparison fails.
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func assinaturaValida(corpo []byte, header, segredo string) bool {
mac := hmac.New(sha256.New, []byte(segredo))
mac.Write(corpo)
hexa := hex.EncodeToString(mac.Sum(nil))
esperado := base64.StdEncoding.EncodeToString([]byte(hexa))
return hmac.Equal([]byte(header), []byte(esperado))
}The three mistakes that break verification
1. Hashing a re-serialized body
By far the most common. Parsing the JSON and dumping it again changes spacing and key order, so the bytes you hash are not the bytes we signed:
# ERRADO — assina bytes diferentes dos que chegaram
corpo = json.dumps(request.json()).encode()
# CERTO — os bytes exatos que vieram na rede
corpo = await request.body()Where the raw body lives, by framework:
| Framework | Raw body |
|---|---|
| FastAPI / Starlette | await request.body() |
| Flask | request.get_data() |
| Django | request.body |
| Express | express.raw({ type: 'application/json' }), then req.body |
| Rails | request.body.read |
| Laravel | $request->getContent() |
The trap in Express and Laravel is that JSON middleware normally consumes the stream first. If it runs before you, the raw body is gone and you cannot verify.
2. Base64-encoding the digest bytes instead of the hex string
A 44-character result means you Base64-encoded the raw 32 bytes. The signature is Base64 over the 64-character hexadecimal string, so the correct value is 88 characters:
digest = hmac.new(segredo.encode(), corpo, hashlib.sha256)
base64.b64encode(digest.digest()) # ERRADO — 44 caracteres
base64.b64encode(digest.hexdigest().encode()) # CERTO — 88 caracteres3. Comparing with ==
==A normal string comparison stops at the first differing byte, and how long it took reveals how many leading characters an attacker guessed right. Given enough attempts that is enough to forge a signature. Use the constant-time comparison your language provides: hmac.compare_digest, crypto.timingSafeEqual, hash_equals, hmac.Equal.
Verify first, then read
Signature checking is the first thing your handler does, not something after reading batch_id and looking it up. Until the signature checks out, no field in the body means anything.
Return 401 and log the request when it fails. Do not act on the body, and do not fall back to trusting it.
After verification
The signature proves the body came from us and was not altered. Two things it does not do:
It does not expire. A request captured off the wire stays valid forever, so it can be replayed. Treat batch_id as an idempotency key: processing the same batch twice should be a no-op.
It does not say the batch is good. A genuine, correctly signed delivery may carry "status": "failed". Verification answers "is this really from Paag?", not "did the batch work?" — for that, read status. See Batch completed.
A failed check does not mean data was lost: the batch result stays available at GET /api/v1/batches/{batch_id} for 7 days, authenticated with your API key. If signatures start failing across the board, the likely cause is a secret that was rotated — a re-registration issues a new secret and invalidates the old one immediately.
