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>
3.6 KiB
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
- Take the raw request body bytes before any JSON parsing.
- Build
signed = webhook-id + "." + webhook-timestamp + "." + body. - Compute
base64(HMAC-SHA256(key, signed)). The key is the base64-decoded part afterwhsec_in the secret you were given. webhook-signatureholds one or more space-separatedv1,<signature>entries (two during a secret rotation). Accept the request if anyv1entry equals yours, comparing in constant time.- Reject a
webhook-timestampmore than 5 minutes from your clock (replay protection). - Remember
webhook-ids you have processed and ignore repeats. Delivery is at least once, and retries and replays reuse the id. - Answer 2xx quickly. Any other status, or a redirect, is retried with backoff. A 429 with
Retry-Afteris 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.