> ## Documentation Index
> Fetch the complete documentation index at: https://doc.quippy-lab.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Check X-Quippy-Signature on every delivery before trusting the payload.

This is the **single most important page** in the webhook docs. A receiver
that skips signature verification can be tricked by anyone who discovers the
URL. Verify before you do anything with the body.

## Why verify

Your webhook URL is effectively public — anyone who guesses it or spots it
in a config file can send `POST` requests to it. Signature verification
proves that a given request actually came from Quippy and wasn't modified
in flight.

## Headers on every delivery

| Header                 | Example            | Purpose                                                                                    |
| ---------------------- | ------------------ | ------------------------------------------------------------------------------------------ |
| `X-Quippy-Event`       | `exam.completed`   | Which event type this is.                                                                  |
| `X-Quippy-Delivery-Id` | `dlv_01abc…`       | Stable ID for this delivery. Log it — it lines up with the admin portal's deliveries view. |
| `X-Quippy-Signature`   | `sha256=<hex>`     | HMAC-SHA256 of the raw request body, prefixed with `sha256=`.                              |
| `Content-Type`         | `application/json` | Always JSON.                                                                               |

## The algorithm

<Steps>
  <Step title="Read the raw bytes of the body">
    Do **not** parse the JSON and re-stringify it. The signature is computed
    over the exact bytes Quippy sent. Any re-serialization (key reordering,
    whitespace changes) will break verification.
  </Step>

  <Step title="Compute HMAC-SHA256(body, signing_secret)">
    Use your endpoint's signing secret (the `whsec_…` string from endpoint
    creation) as the key. Hex-encode the result (lowercase).
  </Step>

  <Step title="Prefix with `sha256=` and compare">
    Build the expected value as `"sha256=" + hex_digest`. Compare against
    the incoming `X-Quippy-Signature` using a **constant-time** comparison
    (not `===` / `==`). A mismatch means reject the request with `401`.
  </Step>
</Steps>

<Warning>
  **Raw body, not parsed JSON.** In Express, mount
  `express.raw({ type: 'application/json' })` on the route and keep `req.body`
  as a `Buffer`. If you let `express.json()` parse it first, you've already
  lost the original byte sequence and the signature will never match.
</Warning>

## Paste-and-run samples

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import express from 'express';
  import crypto from 'crypto';

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

  app.post(
    '/hooks/quippy',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const sig = req.headers['x-quippy-signature'] || '';
      const expected =
        'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
      const a = Buffer.from(sig);
      const b = Buffer.from(expected);
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.sendStatus(401);
      }
      const event = JSON.parse(req.body.toString('utf8'));
      console.log(event.type, event.id);
      res.sendStatus(200);
    },
  );
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os, json
  from flask import Flask, request, abort

  SECRET = os.environ["QUIPPY_WHSEC"].encode()
  app = Flask(__name__)

  @app.post("/hooks/quippy")
  def quippy():
      body = request.get_data()  # raw bytes — do not use request.json here
      sig = request.headers.get("X-Quippy-Signature", "")
      expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(sig, expected):
          abort(401)
      event = json.loads(body)
      print(event["type"], event["id"])
      return "", 200
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require "sinatra"
  require "openssl"
  require "json"

  SECRET = ENV.fetch("QUIPPY_WHSEC")

  post "/hooks/quippy" do
    request.body.rewind
    body = request.body.read  # raw bytes
    sig = request.env["HTTP_X_QUIPPY_SIGNATURE"].to_s
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, body)
    halt 401 unless Rack::Utils.secure_compare(sig, expected)
    event = JSON.parse(body)
    puts "#{event['type']} #{event['id']}"
    200
  end
  ```

  ```go Go (net/http) theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "net/http"
      "os"
  )

  func main() {
      secret := []byte(os.Getenv("QUIPPY_WHSEC"))
      http.HandleFunc("/hooks/quippy", func(w http.ResponseWriter, r *http.Request) {
          body, _ := io.ReadAll(r.Body)
          mac := hmac.New(sha256.New, secret)
          mac.Write(body)
          expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
          if !hmac.Equal([]byte(r.Header.Get("X-Quippy-Signature")), []byte(expected)) {
              http.Error(w, "invalid signature", http.StatusUnauthorized)
              return
          }
          var event map[string]any
          _ = json.Unmarshal(body, &event)
          w.WriteHeader(http.StatusOK)
      })
      http.ListenAndServe(":8080", nil)
  }
  ```

  ```php PHP theme={null}
  <?php
  $secret = getenv('QUIPPY_WHSEC');
  $body = file_get_contents('php://input'); // raw bytes
  $sig = $_SERVER['HTTP_X_QUIPPY_SIGNATURE'] ?? '';
  $expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

  if (!hash_equals($expected, $sig)) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($body, true);
  error_log($event['type'] . ' ' . $event['id']);
  http_response_code(200);
  ```
</CodeGroup>

## Reproduce a signature locally

Given the same body + secret, you should be able to reproduce the signature
on your machine. Useful when debugging a mismatch:

```sh theme={null}
BODY='{"id":"dlv_test","type":"webhook.test","created":"2026-04-20T00:00:00.000Z","institutionId":"inst_demo","data":{}}'
SECRET='whsec_paste_yours_here'
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print "sha256=" $2}'
```

The output should match the `X-Quippy-Signature` header on a delivery with
the same body. If it doesn't:

* You're hashing a modified body (pretty-printed, re-serialized, BOM added).
* Your env var isn't exactly the secret — no extra whitespace, no quotes.
* You grabbed the wrong endpoint's secret.

## Common pitfalls

<AccordionGroup>
  <Accordion title="Parsing JSON before hashing">
    The JSON parsers in most languages drop whitespace and may reorder keys.
    Always hash **before** parsing. In Express use `express.raw(...)`; in
    Flask use `request.get_data()`; in Go read `r.Body` directly.
  </Accordion>

  <Accordion title="Using == or === to compare">
    String equality is timing-leaky. Use `crypto.timingSafeEqual` (Node),
    `hmac.compare_digest` (Python), `hash_equals` (PHP), `hmac.Equal` (Go),
    or `Rack::Utils.secure_compare` (Ruby).
  </Accordion>

  <Accordion title="Slow endpoint → repeated retries">
    The dispatcher times out at **10 seconds**. If your endpoint does heavy
    work synchronously, it will exceed the timeout, Quippy will retry, and
    you'll end up processing the same event multiple times. Respond fast;
    do real work async.
  </Accordion>

  <Accordion title="Dedupe on X-Quippy-Delivery-Id">
    Retries reuse the same `delivery_id`. Persist the ID and short-circuit
    duplicates so a retried delivery doesn't double-count in your system.
  </Accordion>
</AccordionGroup>
