Skip to main content
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

HeaderExamplePurpose
X-Quippy-Eventexam.completedWhich event type this is.
X-Quippy-Delivery-Iddlv_01abc…Stable ID for this delivery. Log it — it lines up with the admin portal’s deliveries view.
X-Quippy-Signaturesha256=<hex>HMAC-SHA256 of the raw request body, prefixed with sha256=.
Content-Typeapplication/jsonAlways JSON.

The algorithm

1

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

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).
3

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

Paste-and-run samples

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);
  },
);

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:
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

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.
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).
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.
Retries reuse the same delivery_id. Persist the ID and short-circuit duplicates so a retried delivery doesn’t double-count in your system.