Webhooks
Set a webhook URL for a form and CalcForms sends an HTTPS POST for every accepted submission, with the inputs and every calculated result.
Setting one up
Open the form in your dashboard and choose Webhook. Enter the URL and, if you want to verify deliveries, a secret. The URL must be public and use HTTPS; addresses on private or internal networks are refused. Test it by submitting the form once. You can pause a webhook without deleting it.
The request
One request per accepted submission, as JSON:
{
"event": "submission.created",
"form_id": 105,
"form_title": "Fence quote",
"form_slug": "fence-quote",
"submission_id": 4821,
"submitted_at": "2026-09-08T14:02:11.000Z",
"submitter_email": "customer@example.com",
"source": "self_serve",
"inputs": { "yard_size": "Average yard", "fence_type": "Wood privacy", "gates": 1 },
"calculated": { "total": 4800 }
}
| Field | Meaning |
|---|---|
event | Always submission.created today. |
source | self_serve for the public link, invite for a private invite, internal for the owner's internal view. |
submitter_email | The email the form collected, or the invited customer's email. Null or absent when the form did not collect one. |
inputs | Every answer, keyed by field id. |
calculated | Every calculated field, keyed by field id, including the ones marked private. The webhook is for systems you control; it is not filtered the way the customer's page is. |
Headers on every request:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CalcForms-Webhook/1.0 |
X-CalcForms-Event | The event name, submission.created. |
X-CalcForms-Signature | Present when you set a secret. sha256=<hex>: an HMAC-SHA256 of the raw request body, keyed with your secret. |
Delivery
Delivery is a single attempt with a five-second timeout. There are no retries, so return a 2xx as soon as you have stored the body, and do your processing afterwards. A slow or failing endpoint never blocks or fails the submission itself; the submission is already saved in your dashboard before the webhook is sent.
Verifying the signature
Compute the HMAC over the exact bytes you received, before any parsing or re-serialisation, and compare it to the header in constant time. Reject the request if they differ.
Node (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.CALCFORMS_WEBHOOK_SECRET;
// express.raw keeps the body as the bytes CalcForms sent, which is what the signature covers.
app.post('/calcforms', express.raw({ type: 'application/json' }), (req, res) => {
const given = req.get('X-CalcForms-Signature') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const ok = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.status(401).end();
res.status(200).end(); // answer first: one attempt, five-second timeout
const submission = JSON.parse(req.body);
// your processing here
});
Python (Flask)
import hashlib, hmac, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CALCFORMS_WEBHOOK_SECRET"].encode()
@app.post("/calcforms")
def calcforms():
given = request.headers.get("X-CalcForms-Signature", "")
expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(given, expected):
abort(401)
submission = request.get_json(force=True)
# your processing here; keep it short or hand off to a queue
return "", 200
Sending a test request yourself
To exercise your endpoint without submitting a form, sign a body with the same secret and send it:
SECRET='your-webhook-secret'
BODY='{"event":"submission.created","form_id":105,"submission_id":1,"inputs":{},"calculated":{}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST https://example.com/calcforms \
-H "Content-Type: application/json" \
-H "User-Agent: CalcForms-Webhook/1.0" \
-H "X-CalcForms-Event: submission.created" \
-H "X-CalcForms-Signature: sha256=$SIG" \
--data-binary "$BODY"
Using Zapier, Make or n8n? Point the webhook at a catch-hook URL from that tool and it will receive the same payload. Note that catch hooks are a paid feature on Zapier.