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

80 lines
3.6 KiB
Markdown

# Receiving webhooks from QQQ
QQQ webhooks follow [Standard Webhooks](https://www.standardwebhooks.com/), 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-id`s 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)
```java
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
```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
```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.