Skip to content
Nigora Zap API

    Sign in Create account

    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.

    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:

    1. 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.
    2. Compare in constant time. A plain comparison (==) can leak, through response time, how many characters were right.
    • 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 the secret field.
    Terminal window
    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:

    Terminal window
    export NIGORA_SEGREDO_DO_WEBHOOK="cole-aqui-o-secret"

    The three examples below are complete servers. Each one answers 401 when the signature does not match and 200 when it does.

    Save it as validar.mjs:

    import { createHmac, timingSafeEqual } from 'node:crypto'
    import { createServer } from 'node:http'
    const segredo = process.env.NIGORA_SEGREDO_DO_WEBHOOK
    if (!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.

    Save it as validar.py:

    import hashlib
    import hmac
    import os
    from 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.

    With the server running, simulate a signed call. The command computes the signature with openssl:

    Terminal window
    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.

    • 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.