Webhooks and signatures

What Intake sends to your systems, and how to make sure a request really came from us.

Two kinds of webhook

Form actions (a form's Webhook or n8n action) run for each new submission. The webhook action sends the submission's fields as JSON. The n8n action sends an envelope:

{
  "form": { "id": "6f1c…", "name": "Contact" },
  "submission": { "id": 42, "createdAt": "2026-10-11T15:47:24Z", "domain": "https://example.com",
                  "data": { "email": "ada@example.com", "message": "Hello" } },
  "files": []
}

Workspace webhooks (under Webhooks) report events across every form: submission.created, submission.claimed (someone picked it up) and action.failed. They carry ids and a link, not the submission's content:

{
  "event": "submission.claimed",
  "workspace": "0b6f…", "createdAt": "2026-10-11T16:02:10Z",
  "form": { "id": "6f1c…", "name": "Contact" },
  "submission": { "id": 42, "url": "…/app/forms/6f1c…/submissions", "claimedBy": "Sam", "pickupSeconds": 312 }
}

Any 2xx answer counts as delivered. Anything else is retried with increasing delays; every attempt and its response is in the dashboard, and you can retry failed deliveries after fixing the cause.

Headers

Verifying signatures

Set a signing secret on the action or workspace webhook. Each request then carries Intake-Signature: t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body> keyed with your secret. Check it against the body exactly as it arrived, before parsing the JSON, and reject old timestamps so a captured request cannot be replayed.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a Buffer, before JSON parsing).
export function verifyIntake(rawBody, header, secret, toleranceSeconds = 300) {
  const p = Object.fromEntries((header || "").split(",").map((kv) => kv.split("=")));
  const expected = createHmac("sha256", secret).update(`${p.t}.`).update(rawBody).digest("hex");
  const sigOk = typeof p.v1 === "string" && p.v1.length === expected.length && timingSafeEqual(Buffer.from(p.v1), Buffer.from(expected));
  return sigOk && Math.abs(Date.now() / 1000 - Number(p.t)) <= toleranceSeconds;
}
// Express: keep the raw body for this route
app.post("/intake", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyIntake(req.body, req.get("Intake-Signature"), process.env.INTAKE_SECRET)) return res.sendStatus(401);
  const submission = JSON.parse(req.body);
  res.sendStatus(204);
});

Python

import hashlib, hmac, time

def verify_intake(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    """raw_body: the request body exactly as received, before JSON parsing."""
    p = dict(kv.split("=", 1) for kv in (header or "").split(",") if "=" in kv)
    expected = hmac.new(secret.encode(), f"{p.get('t', '')}.".encode() + raw_body, hashlib.sha256).hexdigest()
    fresh = p.get("t", "").isdigit() and abs(time.time() - int(p["t"])) <= tolerance_seconds
    return hmac.compare_digest(p.get("v1", ""), expected) and fresh
# Flask
@app.post("/intake")
def intake():
    if not verify_intake(request.get_data(), request.headers.get("Intake-Signature"), os.environ["INTAKE_SECRET"]):
        abort(401)
    submission = request.get_json()
    return "", 204

While you build, intake-cli listen forwards real submissions to your machine signed the same way, with a secret it prints for the session. See the CLI.