Files
james.maesandClaude Opus 5.5 f33e592aef refactor(signing): use core WebhookSigner and WebhookVerifier
Delete the local shared/signing package (WebhookSigner, WebhookVerifier,
WebhookSecret, WebhookVerificationException) and use core
com.kingsrook.qqq.backend.core.utils.webhooks from QQQ 4.1.0-RC.5.
Delivery signing, inbound verification and secret generation and rotation
all go through core. The wire format is unchanged: same headers, v1,
prefix, whsec_ secrets and timestamp tolerance. Endpoints, secret storage
and delivery stay in this qbit.

Refs QRun-IO/qqq#1160 (this repo has issues disabled), architecture review
QRun-IO/qqq#1150 finding F11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:16 -05:00

3.6 KiB

Receiving webhooks from QQQ

QQQ webhooks follow Standard Webhooks, so the official Standard Webhooks libraries verify them too.

What arrives

POST /your/endpoint
Content-Type: application/json
webhook-id: evt_6f1c2d...
webhook-timestamp: 1760300100
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
webhook-attempt: 1

{"id":"evt_6f1c2d...","type":"cms_blog_post.updated","timestamp":"2026-10-12T20:15:00Z","source":"qqq://kof22/table/cms_blog_post","sourceEventId":"...","data":{...}}

How to verify

  1. Take the raw request body bytes before any JSON parsing.
  2. Build signed = webhook-id + "." + webhook-timestamp + "." + body.
  3. Compute base64(HMAC-SHA256(key, signed)). The key is the base64-decoded part after whsec_ in the secret you were given.
  4. webhook-signature holds one or more space-separated v1,<signature> entries (two during a secret rotation). Accept the request if any v1 entry equals yours, comparing in constant time.
  5. Reject a webhook-timestamp more than 5 minutes from your clock (replay protection).
  6. Remember webhook-ids you have processed and ignore repeats. Delivery is at least once, and retries and replays reuse the id.
  7. Answer 2xx quickly. Any other status, or a redirect, is retried with backoff. A 429 with Retry-After is honoured.

Java (QQQ core 4.1.0-RC.5 or later)

import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;

WebhookVerifier verifier = new WebhookVerifier(List.of(WebhookSecret.parse(System.getenv("WEBHOOK_SECRET"))));
verifier.verify(headersAsMap, rawBodyBytes);   // throws WebhookVerificationException with a safe reason

Node.js

import crypto from "node:crypto";

export function verify(headers, rawBody, secret) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
  if (!id || !ts || !sigs) throw new Error("missing headers");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("timestamp outside tolerance");
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest();
  const ok = sigs.split(" ").some((part) => {
    const [version, value] = part.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!ok) throw new Error("bad signature");
}

Python

import base64, hashlib, hmac, time

def verify(headers: dict, raw_body: bytes, secret: str) -> None:
    msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
    if abs(time.time() - int(ts)) > 300:
        raise ValueError("timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    for part in sigs.split(" "):
        version, _, value = part.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return
    raise ValueError("bad signature")

Secret rotation

When the sender rotates, it signs with the old and the new secret for 24 hours (configurable). Switch your receiver to the new secret any time in that window. Get the new value from the endpoint's Signing Secrets, or from the environment variable your admin set as a reference.