Webhooks
A claimed form with a webhook_url POSTs each new submission to that URL. Owners and editors set it; API keys act as editors.
Set up
Set the URL under Form settings > Webhook, or set webhook_url with an account key through PATCH /v1/forms/{id} or update_form_settings. Read the secret with Reveal, GET /v1/forms/{id}/webhook?reveal=true or reveal_secret: true. Rotate in the dashboard replaces the secret at once.
curl -X PATCH https://api.formhaus.dev/v1/forms/Xk3d9QpL2a \
-H "Authorization: Bearer $FORMHAUS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"webhook_url":"https://example.com/api/formhaus"}'| Rule | Value |
|---|---|
| Scheme and port | https:// on port 443 only, no credentials in the URL |
| Address | Public. Private, loopback, link-local and cloud metadata addresses are refused. |
| Checked | On save and on every delivery; the request goes to the resolved IP |
| Redirects | Not followed |
| Secret | whsec_ and 64 hex characters. A new one is created when a URL is set on a form without one. |
| Removal | "webhook_url": "" |
Unclaimed forms send no webhooks.
Request
POST /api/formhaus HTTP/1.1
Content-Type: application/json
User-Agent: Formhaus-Webhooks/1
Formhaus-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Formhaus-Submission-Id: 0b8e8a52-6c1b-4f4e-9d0a-2f4f7d3c1a9e
{"type":"submission.created","form_id":"Xk3d9QpL2a","submission_id":"0b8e8a52-6c1b-4f4e-9d0a-2f4f7d3c1a9e","form_version":2,"version":2,"values":{"email":"ada@example.com"},"created_at":"2026-10-10T12:00:00.123456Z"}| Field | Value |
|---|---|
type | Event type, always submission.created |
form_id | Form id |
submission_id | Submission UUID, same as Formhaus-Submission-Id |
form_version | Definition version the values were validated against |
version | Same value as form_version, kept for older receivers |
values | Validated values |
created_at | RFC 3339, UTC |
Verify
Formhaus-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>. The HMAC key is the full whsec_... string.
- Reject when
|now - t|is over 5 minutes. - Compute the HMAC over the raw body bytes, before JSON parsing.
- Compare in constant time.
- Deduplicate by
Formhaus-Submission-Id: delivery is at least once.
import crypto from "node:crypto";
export function verify(secret, header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}func Verify(secret, header string, body []byte, now time.Time) bool {
t, v1, ok := strings.Cut(strings.TrimPrefix(header, "t="), ",v1=")
unix, err := strconv.ParseInt(t, 10, 64)
if !ok || err != nil || now.Sub(time.Unix(unix, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}Retries
| Response | Result |
|---|---|
2xx | Delivered |
3xx, 4xx except 408, 425, 429 | Cancelled, no retry |
| Blocked address | Cancelled |
| Webhook removed or form deleted | Cancelled |
408, 425, 429, 5xx, timeout, network error | Retried |
Each attempt times out after 10 seconds and reads at most 1 KB of the response. Retries start 30 seconds after the first failure and double up to 3 hours, for 16 attempts over about 22 hours.
Failure email
After 5 failed attempts in a row, owners and members with emails on get one Webhook failing: <form title> email with the last status or error. It is sent at most once per form per 24 hours. A successful delivery resets the count. The Forms page shows a Webhook failing badge.
Delivery log
GET /v1/forms/{id}/webhook returns url, secret (masked without ?reveal=true) and deliveries, the last 100 attempts, newest first.
| Field | Value |
|---|---|
submission_id | Submission UUID |
attempt | Attempt number, from 1 |
status_code | HTTP status, 0 when no response |
error | Generic text, omitted on success |
duration_ms | Request duration |
created_at | Attempt time |
Errors never include a resolved address. The text is one of:
error | Cause |
|---|---|
http <code> | The receiver answered with a non-2xx status |
address not allowed | Private, loopback, link-local or metadata address, or a port other than 443 |
invalid url | The URL failed the rules above |
dns failed | The host did not resolve |
timeout | No answer within 10 seconds |
tls error | Certificate or handshake failure |
connection failed | Any other network failure |
Test send
POST /v1/forms/{id}/webhook/test signs and sends one payload with submission_id 00000000-0000-0000-0000-000000000000 and the value "test" for every field of the latest version. It is not retried or logged.
{ "ok": true, "status_code": 200, "duration_ms": 143 }error is present when ok is false. Without a webhook_url the call answers 422.