Form webhooks: a signed POST for every new response

Updated 2026-10-30

Add a webhook URL to a form. We send it a JSON POST each time a response is stored. The request carries an X-AIFormAPI-Signature header. Recompute it with your webhook secret. That proves the call came from us. This page has the payload and checks in Node and Python.

Add a webhook

bash
curl -X POST https://aiformapi.com/v1/forms/FORM_ID/webhooks \
  -H "Authorization: Bearer $AIFORMAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/aiformapi"}'

The URL must start with https:// and point to a public host. Each form can have up to 3 webhooks. The reply holds the webhook id and its secret:

json
{
  "id": "whk_3Lp8TxN2cV5mQa",
  "url": "https://example.com/hooks/aiformapi",
  "secret": "whsec_THE_SECRET_SHOWN_ONLY_ONCE",
  "created_at": "2026-10-30T09:00:00Z"
}

The secret is shown once. Store it in an environment variable now. If you lose it, delete the webhook and add a new one. We keep the secret in our database as plain text, because we need it to sign each call. No API call returns it again.

Remove a webhook:

bash
curl -X DELETE https://aiformapi.com/v1/forms/FORM_ID/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer $AIFORMAPI_KEY"

You can also add webhooks on the form page in the dashboard, and your agent can call the add_webhook tool. See MCP.

The request we send

We send one POST for each stored response, whatever its source. Human, pasted and agent answers all trigger it.

http
POST /hooks/aiformapi HTTP/1.1
Content-Type: application/json
X-AIFormAPI-Event: response.created
X-AIFormAPI-Signature: t=1761815642,v1=5c1d0f4a8e7b...
json
{
  "event": "response.created",
  "form_id": "frm_4k9QeV2mTc8Xa1",
  "response": {
    "id": "rsp_9Zt3mWq7Lx2KbN",
    "form_id": "frm_4k9QeV2mTc8Xa1",
    "source": "agent_api",
    "submitted_at": "2026-10-30T09:14:02Z",
    "answers": {
      "full_name": "Ada Lovelace",
      "attending": "Yes",
      "guests": 1
    }
  }
}

How we sign it

The header has 2 parts: t is a Unix time in seconds, and v1 is a hex HMAC-SHA256.

text
X-AIFormAPI-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>

The signed text is the value of t, a dot, and the exact raw request body. Use the raw bytes. If you parse the JSON and write it back, the text changes and the check fails.

Verify in Node.js

This example uses Express. It reads the raw body, checks the age of the call and compares the signature in constant time.

js
import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.AIFORMAPI_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // reject calls older than 5 minutes

function verifySignature(rawBody, header, secret) {
  if (!header) return false;

  const parts = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  const age = Math.abs(Date.now() / 1000 - Number(t));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post("/hooks/aiformapi", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!verifySignature(rawBody, req.get("X-AIFormAPI-Signature"), SECRET)) {
    return res.status(401).send("bad signature");
  }

  const event = JSON.parse(rawBody);
  if (event.event === "response.created") {
    console.log("New response", event.response.id, event.response.answers);
    // Save it, send a message, update a sheet. Keep this fast.
  }

  res.sendStatus(200);
});

app.listen(3000);

Verify in Python

This example uses Flask. request.get_data() returns the raw bytes.

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["AIFORMAPI_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300  # reject calls older than 5 minutes

app = Flask(__name__)


def verify_signature(raw_body: bytes, header) -> bool:
    if not header:
        return False
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    try:
        age = abs(time.time() - int(t))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


@app.post("/hooks/aiformapi")
def hook():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get("X-AIFormAPI-Signature")):
        abort(401)

    event = request.get_json(force=True)
    if event["event"] == "response.created":
        response = event["response"]
        print("New response", response["id"], response["answers"])
        # Save it, send a message, update a sheet. Keep this fast.

    return "", 200

Test your handler without a real response

This script signs a sample payload with your secret and posts it to your local server. If your handler returns 200, your check works. Change the secret and it should return 401.

python
import hashlib
import hmac
import json
import os
import time

import requests

secret = os.environ["AIFORMAPI_WEBHOOK_SECRET"].encode()
body = json.dumps(
    {
        "event": "response.created",
        "form_id": "frm_test",
        "response": {
            "id": "rsp_test",
            "form_id": "frm_test",
            "source": "human",
            "submitted_at": "2026-10-30T09:14:02Z",
            "answers": {"full_name": "Test Person"},
        },
    }
)
t = str(int(time.time()))
sig = hmac.new(secret, f"{t}.{body}".encode(), hashlib.sha256).hexdigest()

r = requests.post(
    "http://localhost:3000/hooks/aiformapi",
    data=body,
    headers={"Content-Type": "application/json", "X-AIFormAPI-Signature": f"t={t},v1={sig}"},
    timeout=10,
)
print(r.status_code)

Delivery rules

  • We call your URL after the response is already stored and the person has their confirmation. A slow webhook never slows down a form.
  • Your server has 5 seconds to answer with a 2xx status.
  • We do not follow redirects. Give us the final URL.
  • If the call fails, we try 1 more time.
  • We keep the last status, error and time of the delivery for each webhook.
  • Answer fast with a 2xx status and do slow work afterward in a queue or background job.
  • A retry can deliver the same response twice. Use response.id to skip duplicates.
  • A webhook is the best way to follow new answers. It uses none of your 30 requests per minute. See Rate limits and quotas.

Checklist

  1. Read the raw body, not the parsed JSON.
  2. Split the header on commas, then on the first equals sign.
  3. Reject if t is too old.
  4. Compute HMAC-SHA256(secret, t + "." + body) as hex.
  5. Compare in constant time.
  6. Return 200 quickly.