# Verify signatures (https://docs.pulsarml.com/webhooks/signatures)

Check that every webhook request really came from Pulsar and wasn't altered on the way.



Your endpoint is a public URL, so anyone can send it a request. Every notification from Pulsar carries an `X-Pulsar-Signature` header, a hash of the request body computed with your **signing secret**. Only Pulsar and you know the secret, so a matching signature proves the request came from Pulsar and that the body wasn't changed.

The signature is the **HMAC-SHA256** of the **raw request body**, keyed with your signing secret and written as **lowercase hex**.

<Callout type="warn" title="Use the secret exactly as Pulsar gives it to you">
  The signing secret is a 32-character string. Use its text (UTF-8 bytes) as the HMAC key, as-is. &#x2A;*Don't Base64-decode or hex-decode it.** Decoding usually succeeds without an error but produces the wrong key, so every signature check fails.
</Callout>

## How to verify a request [#how-to-verify-a-request]

<Steps>
  <Step>
    ### Read the raw body [#read-the-raw-body]

    Compute the signature over the **exact bytes** Pulsar sent, before any JSON parsing. Parsing and re-serializing the JSON can change spacing or key order, which changes the hash. Most frameworks give you the raw body separately; see the samples below.
  </Step>

  <Step>
    ### Compute the expected signature [#compute-the-expected-signature]

    Compute HMAC-SHA256 of the raw body using your signing secret as the key, and hex-encode the result.
  </Step>

  <Step>
    ### Compare it with the header [#compare-it-with-the-header]

    Compare your result with the `X-Pulsar-Signature` header using a **constant-time** comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js). A plain `==` can leak, through response timing, how much of a guessed signature was right.
  </Step>

  <Step>
    ### Accept or reject [#accept-or-reject]

    If the signatures match, handle the event and return `2XX`. If the header is missing or doesn't match, return `401` and ignore the request.
  </Step>
</Steps>

<Callout title="Test your check with a ping first">
  Pulsar doesn't retry a `4XX` response (see [Retries](/webhooks#retries)). So if your secret is misconfigured, real events are rejected and stay undelivered until Pulsar replays them. Ask for a [ping](/webhooks#ping-events) and confirm your endpoint returns `2XX` before you rely on it.
</Callout>

## Sample code [#sample-code]

Both samples read the secret from a `PULSAR_WEBHOOK_SECRET` environment variable.

<Tabs items="['Python (Flask)', 'Node.js (Express)']">
  <Tab value="Python (Flask)">
    ```python
    import hashlib
    import hmac
    import os

    from flask import Flask, request

    app = Flask(__name__)
    SECRET = os.environ["PULSAR_WEBHOOK_SECRET"].encode("utf-8")


    @app.post("/webhooks/pulsar")
    def pulsar_webhook():
        signature = request.headers.get("X-Pulsar-Signature", "")

        # The exact bytes Pulsar sent, before any JSON parsing.
        body = request.get_data()
        expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()

        if not hmac.compare_digest(expected, signature):
            return "", 401

        notification = request.get_json()
        if notification["eventType"] == "Ping":
            return "", 200

        # Queue the work and respond straight away; see "Respond with a 2XX, quickly".
        enqueue(notification)
        return "", 202
    ```
  </Tab>

  <Tab value="Node.js (Express)">
    ```js
    const crypto = require('node:crypto');
    const express = require('express');

    const app = express();
    const SECRET = process.env.PULSAR_WEBHOOK_SECRET;

    // express.raw keeps the exact bytes Pulsar sent. Don't use express.json()
    // on this route: it parses the body before you can check the signature.
    app.post('/webhooks/pulsar', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = Buffer.from(req.get('X-Pulsar-Signature') ?? '');
      const expected = Buffer.from(
        crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'),
      );

      if (signature.length !== expected.length || !crypto.timingSafeEqual(signature, expected)) {
        return res.sendStatus(401);
      }

      const notification = JSON.parse(req.body.toString('utf8'));
      if (notification.eventType === 'Ping') return res.sendStatus(200);

      // Queue the work and respond straight away; see "Respond with a 2XX, quickly".
      enqueue(notification);
      res.sendStatus(202);
    });
    ```
  </Tab>
</Tabs>

## Replayed requests [#replayed-requests]

The signature covers the body only, so anyone who gets hold of a copy of a request, for example from a log, could send it to you again unchanged and it would still pass the check. Protect against that the same way you [handle duplicates](/webhooks#duplicates-and-ordering): record each `eventId` you've processed and skip one you've already seen.

Don't reject requests just because `eventTimestamp` is old. Retries and replays deliberately resend the original event, with its original timestamp, possibly much later.
