Validate the webhook signature in Node.js, PHP and Python
By the end, your server will reject any webhook call that was not signed by the API. Without this, anyone who finds your address could send fake events.
How the signature works
Section titled “How the signature works”Each call carries the x-nigora-signature header, in the format sha256=<hex>. The value is the HMAC-SHA256 of the exact bytes of the body, computed with the instance secret and written in hexadecimal.
So your server does the same calculation and compares the result with the header. Two rules:
- Use the raw body, exactly as it arrived. If you parse the JSON and serialize it again, the bytes change and the signature no longer matches.
- Compare in constant time. A plain comparison (
==) can leak, through response time, how many characters were right.
Prerequisites
Section titled “Prerequisites”- A server that already receives webhooks, like the one in the Receive messages by webhook tutorial.
- The instance secret. Read it with
GET /v1/instances/{instanceId}/webhooks: it comes in thesecretfield.
curl https://api.zap.nigora.com.br/v1/instances/INSTANCIA_ID/webhooks \ -H "Authorization: Bearer TOKEN"Keep the secret in an environment variable, never in the code:
export NIGORA_SEGREDO_DO_WEBHOOK="cole-aqui-o-secret"1. Pick your language
Section titled “1. Pick your language”The three examples below are complete servers. Each one answers 401 when the signature does not match and 200 when it does.
Node.js
Section titled “Node.js”Save it as validar.mjs:
import { createHmac, timingSafeEqual } from 'node:crypto'import { createServer } from 'node:http'
const segredo = process.env.NIGORA_SEGREDO_DO_WEBHOOKif (!segredo) { console.error('Defina NIGORA_SEGREDO_DO_WEBHOOK.') process.exit(1)}
// `corpoBruto` é um Buffer: o corpo exatamente como chegou.function assinaturaValida(corpoBruto, cabecalho) { const esperada = 'sha256=' + createHmac('sha256', segredo).update(corpoBruto).digest('hex') const a = Buffer.from(esperada) const b = Buffer.from(cabecalho ?? '') return a.length === b.length && timingSafeEqual(a, b)}
createServer((req, res) => { const pedacos = [] req.on('data', (pedaco) => pedacos.push(pedaco)) req.on('end', () => { const corpoBruto = Buffer.concat(pedacos) if (!assinaturaValida(corpoBruto, req.headers['x-nigora-signature'])) { res.writeHead(401).end() return } res.writeHead(200).end('ok') console.log('Assinatura válida:', req.headers['x-nigora-event']) })}).listen(3000, () => console.log('Ouvindo na porta 3000'))Run it with node validar.mjs.
Save it as validar.php:
<?php$segredo = getenv('NIGORA_SEGREDO_DO_WEBHOOK');if (!$segredo) { http_response_code(500); exit;}
// O corpo bruto, exatamente como chegou.$corpo = file_get_contents('php://input');$recebida = $_SERVER['HTTP_X_NIGORA_SIGNATURE'] ?? '';$esperada = 'sha256=' . hash_hmac('sha256', $corpo, $segredo);
if (!hash_equals($esperada, $recebida)) { http_response_code(401); exit;}
http_response_code(200);error_log('Assinatura válida: ' . ($_SERVER['HTTP_X_NIGORA_EVENT'] ?? ''));echo 'ok';Run it with php -S 127.0.0.1:3000 validar.php.
Python
Section titled “Python”Save it as validar.py:
import hashlibimport hmacimport osfrom http.server import BaseHTTPRequestHandler, HTTPServer
segredo = os.environ["NIGORA_SEGREDO_DO_WEBHOOK"].encode("utf-8")
def assinatura_valida(corpo_bruto: bytes, cabecalho: str) -> bool: esperada = "sha256=" + hmac.new(segredo, corpo_bruto, hashlib.sha256).hexdigest() return hmac.compare_digest(esperada.encode("utf-8"), cabecalho.encode("utf-8"))
class Receptor(BaseHTTPRequestHandler): def do_POST(self): corpo_bruto = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not assinatura_valida(corpo_bruto, self.headers.get("x-nigora-signature", "")): self.send_response(401) self.end_headers() return self.send_response(200) self.end_headers() self.wfile.write(b"ok") print("Assinatura válida:", self.headers.get("x-nigora-event"))
HTTPServer(("127.0.0.1", 3000), Receptor).serve_forever()Run it with python3 validar.py.
2. Test it on your machine
Section titled “2. Test it on your machine”With the server running, simulate a signed call. The command computes the signature with openssl:
CORPO='{"event":"message.received","instanceId":"teste","timestamp":"2026-11-02T14:30:00.000Z","data":{}}'ASSINATURA="sha256=$(printf '%s' "$CORPO" | openssl dgst -sha256 -hmac "$NIGORA_SEGREDO_DO_WEBHOOK" -hex | sed 's/^.* //')"
curl -i -X POST http://127.0.0.1:3000/ \ -H "Content-Type: application/json" \ -H "x-nigora-event: message.received" \ -H "x-nigora-signature: $ASSINATURA" \ -d "$CORPO"Expected result: HTTP/1.1 200 OK and, in the server terminal, Assinatura válida: message.received.
Now change one letter of the body and repeat only the curl, without recomputing the signature. Expected result: HTTP/1.1 401.
If something goes wrong
Section titled “If something goes wrong”- Always
401: check that the secret belongs to the right instance and that your framework is not reading the JSON before you grab the raw body. - Secret rotation: after
POST /v1/instances/{instanceId}/webhooks/secret/regenerate, the old secret stops working. Update the environment variable.
Next steps
Section titled “Next steps”- Webhooks: events, retries, history and resending.
- Limits and errors: format of the API errors.
- Connect with a pairing code: connect the number without using the camera.
Receive WhatsApp messages by webhook Next
Connect your number to WhatsApp with a pairing code