The payload quickform-server posts on every submission, the X-QuickForm-Signature HMAC, and how an agent verifies it.
The webhook is the primary way out of quickform. The PWA and the portal are how a person sees an answer; the webhook is how an agent hears about it — in the second it lands, with no poll interval in between.
Targets are configured per form:
{ "webhooks": [{ "url": "https://agent.acme.example/hooks/quickform", "secret": "whsec_…" }] }
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | pks-agent-quickform |
X-QuickForm-Event | the event name, e.g. submission.created |
X-QuickForm-Signature | sha256=<hex(hmac)> — only when the target has a secret |
{
"event": "submission.created",
"sentAt": "2026-08-14T15:34:34Z",
"data": {
"id": "20260814T153434.505417Z-300a648f",
"org": "acme",
"form": "contact",
"receivedAt": "2026-08-14T15:34:34.505417Z",
"origin": "https://acme.example",
"referer": "https://acme.example/kontakt",
"ip": "203.0.113.10",
"answers": { "name": "Ada Lovelace", "message": "…" },
"submitFields": {},
"read": false
}
}
data is the stored submission record, so it also carries userAgent and
raw — the untouched request body, kept so nothing a customer sent is ever lost
to our parsing choices. answers is QuickForm's flat logicalName → value map,
exactly as the runtime sent it. submitFields is the nested block the core runtime uses for consents
and opt-ins; a definition without one submits {} and does not crash.
The HMAC is sha256 over the exact bytes of the request body, keyed with the
form's secret. Verify against the raw body, before any JSON parsing — a re-encode
changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header ?? "");
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header or "", expected)
A target with no secret gets an unsigned POST. That is fine on a loopback receiver during development and is not fine on the open internet: without the signature, anything that learns your webhook URL can invent answers.
The useful shape is: verify, drop the answer somewhere the agent already
watches, return 200 immediately. Anything slower turns the 12-second timeout
into your problem, and the delivery is not retried.
pks-agent-inbox is the natural landing place — the webhook receiver writes the
submission into an inbox the agent already polls over MCP, so the agent needs no
new transport and no public endpoint of its own.