Webhooks

preview

The payload quickform-server posts on every submission, the X-QuickForm-Signature HMAC, and how an agent verifies it.

Author: Poul Kjeldager
Platform: linuxmacoswindowsdocker
Category: agents

Webhooks

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_…" }] }

Delivery

  • Fired after the submission is durable on disk. A dead webhook can never cost a submitter their answer.
  • Sequential, best-effort, one 12-second timeout per target. There is no retry queue: if your receiver was down, read the submission from the API instead.
  • Headers:
HeaderValue
Content-Typeapplication/json
User-Agentpks-agent-quickform
X-QuickForm-Eventthe event name, e.g. submission.created
X-QuickForm-Signaturesha256=<hex(hmac)> — only when the target has a secret

Payload

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

Verifying the signature

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.

Handing it to an agent

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.