Webhooks

After an accepted submission is saved, FormBeam POSTs signed JSON to your HTTPS endpoint. The submitter is never blocked on your server.

Enable in the dashboard
Form settings, next to email and auto-reply.

1. Open the form → Webhook.

2. Paste an HTTPS URL (webhook.site works for a smoke test).

3. Save. Copy the whsec_ secret once. Rotate later if it leaks.

4. Use Send test ping, then watch the delivery log.

Payload
Stable JSON. Retries reuse the same id and the same bytes.
{
"id": "evt_01J...",
"type": "submission.accepted",
"created_at": "2026-09-11T21:04:00.000Z",
"livemode": true,
"data": {
"form": { "id": "...", "slug": "contact", "name": "Contact" },
"submission": {
"id": "42",
"created_at": "2026-09-11T21:03:59.000Z",
"fields": { "name": "Ada", "email": "ada@example.com" },
"files": [{ "name": "ada.pdf", "size": 18221 }],
"meta": { "ip": "203.0.113.10", "user_agent": "...", "referrer": "..." }
}
}
}

File URLs are omitted unless FormBeam already has a safe signed URL. Test pings use type: "ping" and do not create a submission.

Headers and signature
HMAC-SHA256 over the exact raw body bytes. Do not re-serialize JSON before verifying.
Content-Type: application/json; charset=utf-8
User-Agent: FormBeam-Webhook/1.0
X-FormBeam-Event: submission.accepted
X-FormBeam-Delivery: <uuid>
X-FormBeam-Event-Id: evt_...
X-FormBeam-Timestamp: 1770000000
X-FormBeam-Signature: t=1770000000,v1=<hex>

signed_payload = {timestamp}.{raw_body}. HMAC key is the full whsec_... string. Compare with a constant-time function. Reject if |now - timestamp| > 300s. Deduplicate on X-FormBeam-Event-Id / payload id.

Node

const crypto = require("crypto");
function verifyFormBeamWebhook({ secret, rawBody, signatureHeader, timestampHeader }) {
const skew = 300;
const ts = Number(timestampHeader);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > skew) {
throw new Error("timestamp outside window");
}
const signed = `${ts}.${rawBody}`;
const expected =
"t=" + ts + ",v1=" + crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("bad signature");
}
}
// HMAC key is the full whsec_... string (not base64-decoded).

Go

mac := hmac.New(sha256.New, []byte(secret)) // full whsec_... string
fmt.Fprintf(mac, "%d.", timestamp)
mac.Write(rawBody)
v1 := hex.EncodeToString(mac.Sum(nil))
expected := fmt.Sprintf("t=%d,v1=%s", timestamp, v1)
if !hmac.Equal([]byte(expected), []byte(header)) {
return errors.New("bad signature")
}
Retries and timeouts

Success is HTTP 2xx within 5 seconds. Redirects are not followed.

Eight attempts over about 24 hours: 0s, 30s, 2m, 10m, 30m, 2h, 6h, 24h (with jitter).

410 Gone stops retries. 429, 5xx, timeouts, and connect errors retry. Other 4xx are terminal. After the last failure, auto-retry stops; the webhook stays enabled so you can fix the URL and redeliver.

Local testing

Use webhook.site for a public HTTPS URL. ngrok/Cloudflare Tunnel also work. Localhost and RFC1918 addresses are rejected (SSRF).

FAQ

Submit worked but Slack did not. Check the delivery log on the form. Webhooks are async; a 500 from Slack still saved the lead. Redeliver after you fix the URL.

Old secret after rotate. In-flight and new deliveries sign with the secret stored on the webhook at send time. Update your receiver.