Skip to content

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.

bash
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"}'
RuleValue
Scheme and porthttps:// on port 443 only, no credentials in the URL
AddressPublic. Private, loopback, link-local and cloud metadata addresses are refused.
CheckedOn save and on every delivery; the request goes to the resolved IP
RedirectsNot followed
Secretwhsec_ 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 ​

http
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"}
FieldValue
typeEvent type, always submission.created
form_idForm id
submission_idSubmission UUID, same as Formhaus-Submission-Id
form_versionDefinition version the values were validated against
versionSame value as form_version, kept for older receivers
valuesValidated values
created_atRFC 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.

  1. Reject when |now - t| is over 5 minutes.
  2. Compute the HMAC over the raw body bytes, before JSON parsing.
  3. Compare in constant time.
  4. Deduplicate by Formhaus-Submission-Id: delivery is at least once.
js
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);
}
go
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 ​

ResponseResult
2xxDelivered
3xx, 4xx except 408, 425, 429Cancelled, no retry
Blocked addressCancelled
Webhook removed or form deletedCancelled
408, 425, 429, 5xx, timeout, network errorRetried

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.

FieldValue
submission_idSubmission UUID
attemptAttempt number, from 1
status_codeHTTP status, 0 when no response
errorGeneric text, omitted on success
duration_msRequest duration
created_atAttempt time

Errors never include a resolved address. The text is one of:

errorCause
http <code>The receiver answered with a non-2xx status
address not allowedPrivate, loopback, link-local or metadata address, or a port other than 443
invalid urlThe URL failed the rules above
dns failedThe host did not resolve
timeoutNo answer within 10 seconds
tls errorCertificate or handshake failure
connection failedAny 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.

json
{ "ok": true, "status_code": 200, "duration_ms": 143 }

error is present when ok is false. Without a webhook_url the call answers 422.

Formhaus is MIT licensed. Legal