Guide to Formstack Webhooks: Features and Best Practices
Formstack Forms notifies your systems the moment someone submits a form. The mechanism is a submit action labelled Send Data to an External URL (WebHook), and it posts that submission's answers to a URL you control, so a lead reaches your CRM, an order reaches your fulfilment queue, or a support request reaches your ticketing system without anyone exporting a spreadsheet.
Which Formstack. This guide covers Formstack Forms, the form builder at formstack.com. Formstack Documents (formerly WebMerge) has its own separate Webhook Delivery feature with a different payload, and Formstack Sign (formerly InsureSign) and the Salesforce-packaged products are different surfaces again. Nothing below applies to those.
This guide covers how the WebHook submit action delivers, the X-FS-Signature HMAC and the details of it that Formstack no longer publishes (confirmed here against real deliveries), why there are no event types to subscribe to, and the best practices for production.
What are Formstack webhooks?
A Formstack WebHook is a per-form submit action. You add it to a form, give it a URL, and Formstack sends one HTTP POST per submission, in real time. The default content type is application/x-www-form-urlencoded; JSON is a per-WebHook setting.
Signing is a custom HMAC scheme, not Standard Webhooks and not Svix. Formstack computes HMAC-SHA256 over the raw request body bytes, keyed with an HMAC Key you set on that WebHook, and sends the digest as lowercase hex, prefixed sha256=, in X-FS-Signature. Nothing else goes into the signed content: no timestamp, no nonce, no URL, no method. Two things about that arrangement shape every handler you write. The HMAC Key is optional and off by default, so an unconfigured WebHook is delivered entirely unsigned. And the key is per WebHook, not per account, so two forms are two secrets.
Formstack webhook features
| Feature | Details |
|---|---|
| Configuration | Per form, in the form builder (Emails & Actions) or via the v2025 API (POST /forms/{formId}/webhooks) |
| Signature header | X-FS-Signature by default, renamed by the WebHook's Custom HMAC Header field |
| Signature scheme | Custom: HMAC-SHA256 over the raw request body, lowercase hex, sent as sha256=<hex> |
| Signing key | The per-WebHook HMAC Key. Optional, off by default, and not the API access token |
| Replay window | None. No timestamp or nonce is signed, so a captured delivery stays valid |
| Content type | application/x-www-form-urlencoded (default) or JSON |
| Payload | A flat map of that form's field keys, plus FormID, UniqueID, and HandshakeKey when a Shared Secret is set |
| Event types | None. A WebHook fires on one thing, a form submission |
| Filtering | Routing Logic, a sender-side filter on the submitted answers |
| SDK | No official library for verification. Use stdlib crypto / hmac |
| Delivery | Failures go to the WebHook's error email addresses; Formstack publishes source IPs for firewall allowlisting |
Common events
Formstack has no event types, and this is the single biggest structural difference from most webhook providers you have integrated. A WebHook fires on exactly one thing: a form submission. There is no event-type header, no event-type field in the body, no list of event names, and nothing to subscribe to. Real deliveries carry no delivery-ID or timestamp header either; beyond the signature and standard HTTP headers, the only extras are Datadog tracing headers. form.submitted, submission.created and form_submission all look plausible and none of them exist, so do not write a handler that switches on one.
Route on FormID instead. One endpoint commonly serves several forms, and the form ID is what tells them apart. The User-Agent on observed deliveries also carries it (FormstackWebhook/1.0 (Form 6606394)), but Formstack does not document that format, so read the body field.
The only filtering that exists is Routing Logic, a per-WebHook conditional filter on how the form was answered. It decides whether a given submission is sent at all, and your handler sees no trace of it. It is a sender-side filter, not an event subscription.
The body itself is a flat map of submitted field key to value, and the keys are the form's own field labels, so the shape is different for every form and cannot be hardcoded. Three keys sit alongside them:
| Key | Use it as |
|---|---|
FormID | The routing key: which form this submission came from |
UniqueID | The idempotency key: which submission this is |
HandshakeKey | The WebHook's Shared Secret, echoed back in the body (see Setting up Formstack webhooks) |
Read them defensively. Formstack's API reference shows only FormID and UniqueID in its example webhook schema, and real deliveries added HandshakeKey and nothing else, so there is no Timestamp, FormName or SubmissionID to depend on. Treat both IDs as strings.
The key format is configurable per WebHook, through postDataFieldKeys: field_names (the default), field_ids, api_friendly_field_names, internal_labels or internal_labels_api_friendly. Choose field_ids if any two fields on the form could ever share a label, because with the label-based formats duplicate labels collapse and only the last occurrence is sent. Two Full Name: fields holding "Jane Doe" and "John Doe" arrive as one value, "John Doe", with no error anywhere to tell you the first was dropped.
To find out exactly what one form will send, ask Formstack for its generated schema:
GET https://www.formstack.com/api/v2025/forms/{formId}/webhooks/openapi
Setting up Formstack webhooks
In the form builder, the path is Form Settings > Emails & Actions > Advance Settings > Add Webhook (the "Advance" spelling is Formstack's own). The URL Address field is the only required one, but the important first step is the optional one next to it: set an HMAC Key. Without a key, Formstack sends no signature at all, and retrofitting one onto a live integration means a window where you either accept unsigned traffic or reject everything.
The same WebHook can be created through the v2025 API:
curl -X POST "https://www.formstack.com/api/v2025/forms/$FORM_ID/webhooks" \
-H "Authorization: Bearer $FORMSTACK_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Order intake",
"url": "https://your-app.example.com/webhooks/formstack",
"contentType": "json",
"postDataFieldKeys": "field_ids",
"hmacSecret": "'"$FORMSTACK_HMAC_KEY"'",
"errorEmails": "alerts@example.com"
}'
The secret you verify with is hmacSecret, the HMAC Key for that WebHook. Keep it clearly separate from FORMSTACK_ACCESS_TOKEN, which is the OAuth2 or Personal Access Token that authenticates the management API. They are different credentials with different lifetimes, and verifying with the wrong one produces a mismatch indistinguishable from a forged request.
The builder also offers a Shared Secret (also called the Handshake Key), which does not sign anything. When it's set, Formstack posts it back in the body as a HandshakeKey field, inside the signed bytes, while the HMAC Key itself is never sent. Verify with the HMAC Key, and keep HandshakeKey out of anything you log or store.
Two settings deserve a look while you are in there. errorEmails is where failed deliveries are reported, and it is the only failure signal Formstack gives you. And if the form collects card payments, sending full credit-card data over the WebHook requires an HTTPS URL, an HMAC Key, at least one error email, and a PCI acknowledgement, all together. Formstack is explicit that if such a delivery fails, the submission is kept but the card data is not, and there is no way to fetch it afterwards, which is why the error address is mandatory rather than advisory.
For local development, the Hookdeck CLI (hookdeck listen 3000 formstack --path /webhooks/formstack) gives you a public HTTPS URL to paste into the URL Address field, plus an inspector. Set an HMAC Key and submit the form to see a real signed delivery rather than one you constructed yourself.
Securing Formstack webhooks
Verify HMAC-SHA256 over the raw request body bytes, keyed with the WebHook's HMAC Key, and compare against the hex digest in X-FS-Signature. Read the header name from configuration rather than hardcoding it, because the Custom HMAC Header field renames it. Strip the sha256= prefix (and still accept a bare digest), normalise case before comparing hex, and compare in constant time with a length guard first. Above all, fail closed: because signing is off by default, "no key configured, accept anyway" is the tempting fallback, and it hands your endpoint to anyone who learns the URL.
Formstack's current public documentation states the header name and the existence of the HMAC Key field, but it does not name the algorithm, the encoding or the prefix. The developer page that did is still linked from the bottom of the help article and now returns a 404. We confirmed the scheme against real deliveries instead: recomputing HMAC-SHA256 over the raw body with the HMAC Key reproduces the header exactly as lowercase hex after sha256=, and the base64 form of the same HMAC does not match. After we changed the HMAC Key and left the Shared Secret alone, the next delivery verified only with the new key and still carried the old Shared Secret in its body, so the Shared Secret plays no part as a signing key. Here is one of those deliveries, from a form with no answer fields, sent with the HMAC Key test1 and the Shared Secret test:
Content-Type: application/x-www-form-urlencoded; charset=utf-8
X-FS-Signature: sha256=30dff7f180b6d69eab397a5d51719474df490b730514c253e8b5832d3b51b970
FormID=6606394&UniqueID=1500878955&HandshakeKey=test
It makes a handy test vector: your verifier should accept it with the key test1 and reject it with test.
A related trap: X-FS-Signature is also FastSpring's header. FastSpring is an unrelated e-commerce company whose scheme uses a base64 digest and an events[] envelope. If you find yourself writing .digest("base64") or iterating an events array, you have imported the wrong provider's scheme.
const crypto = require("crypto");
const express = require("express");
const app = express();
// The Custom HMAC Header field renames this, so read it from config.
// Lowercase: Express lowercases incoming header names.
const headerName = (
process.env.FORMSTACK_SIGNATURE_HEADER || "x-fs-signature"
).toLowerCase();
function verifyFormstackWebhook(rawBody, signatureHeader, hmacKey) {
// Fail closed: an unset key must never mean "accept anyway".
if (!signatureHeader || !hmacKey) return false;
// Formstack sends `sha256=<hex>`. Strip the prefix (a bare digest also passes),
// trim, lowercase.
const received = signatureHeader
.trim()
.replace(/^sha256=/i, "")
.trim()
.toLowerCase();
const expected = crypto
.createHmac("sha256", hmacKey)
.update(rawBody)
.digest("hex");
const a = Buffer.from(received, "utf8");
const b = Buffer.from(expected, "utf8");
// Length guard first: timingSafeEqual throws on a length mismatch.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Capture the exact bytes before any parser touches them.
const saveRawBody = (req, res, buf) => {
req.rawBody = buf;
};
app.post(
"/webhooks/formstack",
// Both parsers: the content type is chosen per WebHook, and each is a no-op
// when the request's content type does not match.
express.urlencoded({ extended: true, verify: saveRawBody }),
express.json({ verify: saveRawBody }),
(req, res) => {
const hmacKey = process.env.FORMSTACK_HMAC_KEY;
if (!hmacKey) return res.sendStatus(500); // misconfigured, not forged
if (!verifyFormstackWebhook(req.rawBody, req.headers[headerName], hmacKey)) {
return res.sendStatus(401);
}
// Verified. Route on FormID, dedupe on UniqueID, then acknowledge.
const { FormID, UniqueID } = req.body;
processQueue.add({ formId: FormID, uniqueId: UniqueID, fields: req.body });
res.sendStatus(200);
},
);
import hashlib
import hmac
import json
import os
import re
import urllib.parse
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
app = FastAPI()
HEADER_NAME = (os.environ.get("FORMSTACK_SIGNATURE_HEADER") or "x-fs-signature").lower()
def verify_formstack_webhook(raw_body: bytes, signature_header, hmac_key) -> bool:
if not signature_header or not hmac_key: # fail closed
return False
received = re.sub(r"^sha256=", "", signature_header.strip(), flags=re.IGNORECASE)
expected = hmac.new(hmac_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
# Compare BYTES: compare_digest raises TypeError on non-ASCII str, and header
# values arrive latin-1 decoded, so str comparison turns a hostile header into a 500.
return hmac.compare_digest(
received.strip().lower().encode("utf-8", "replace"), expected.encode("ascii")
)
@app.post("/webhooks/formstack")
async def formstack_webhook(request: Request, background_tasks: BackgroundTasks):
raw = await request.body() # bytes first, parse second
hmac_key = os.environ.get("FORMSTACK_HMAC_KEY")
if not hmac_key:
raise HTTPException(status_code=500, detail="Webhook secret not configured")
if not verify_formstack_webhook(raw, request.headers.get(HEADER_NAME), hmac_key):
raise HTTPException(status_code=401, detail="Invalid signature")
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
fields = json.loads(raw)
else:
fields = dict(urllib.parse.parse_qsl(raw.decode("utf-8")))
background_tasks.add_task(process_submission, fields)
return {"received": True}
The raw-body handling is doing more work here than it does for a JSON-only provider. The default content type is urlencoded, and the digest covers the raw urlencoded bytes, not a re-encoded form of the parsed dictionary. Re-encoding reorders keys, re-escapes characters (+ against %20, %2F against /) and merges duplicates, and any one of those changes the bytes enough to break the digest permanently. Capture the bytes before parsing, in every framework: verify hooks in Express, await req.text() in Next.js, await request.body() in FastAPI. Calling req.formData() or await request.form() first destroys the evidence.
Make Formstack webhooks production-ready. Hookdeck Event Gateway verifies
X-FS-Signatureon arrival, deduplicates, and durably queues every submission before it reaches your handler.
Formstack webhook limitations and pain points
Signing is optional and off by default
The Problem: A WebHook created without an HMAC Key delivers completely unsigned, and nothing in the Formstack UI warns you that it is unauthenticated. Anyone who learns the endpoint URL can post a fake submission that your handler cannot distinguish from a real one.
Why It Happens: The HMAC Key sits among the optional fields, alongside the WebHook name and the content type, so a working integration can be built end to end without ever touching it.
Workarounds:
- Set the HMAC Key as the first configuration step, before pointing the WebHook at anything real.
- Fail closed in code: treat a missing key as a misconfiguration (
500) and a missing or invalid header as a rejection (401). Never add an unsigned fallback path, because it becomes the path everything uses. - Audit existing WebHooks for an empty
hmacSecretacross every form, not just the one you are working on.
How Hookdeck Can Help: Configure the signing secret once on the Hookdeck source and unverified requests are rejected at the edge, so an unsigned or forged delivery never reaches your application at all.
The signed bytes are the raw urlencoded body
The Problem: Verification fails on every delivery, the key is correct, and the code looks right. This is where most Formstack integrations lose a day.
Why It Happens: The default content type is application/x-www-form-urlencoded, and the HMAC covers the exact bytes on the wire. Framework middleware parses that body into a dictionary before your handler runs, and reconstructing the string from the dictionary produces different bytes: different key order, different percent-encoding, merged duplicates.
Workarounds:
- Capture the raw body before parsing, and keep it as bytes rather than a decoded string.
- Mount both a urlencoded and a JSON parser on the route, since the content type is a per-WebHook setting and one endpoint often serves several forms configured by different people.
- When debugging, log the body length and the header length. The header is 71 characters:
sha256=plus a 64-character hex digest. If your comparison sees 71 against 64, the prefix was not stripped.
How Hookdeck Can Help: Every request is stored exactly as received, bytes and headers included, so you can compare what Formstack actually sent against what your handler computed instead of inferring it from a parsed object.
There is no replay protection
The Problem: No timestamp and no nonce are part of the signed content, so a captured Formstack delivery stays valid forever. Replaying it produces a fresh, correctly signed request as many times as an attacker likes.
Why It Happens: The scheme signs the body and only the body. There is no staleness window to enforce, and inventing one is impossible because there is nothing in the signature to check it against.
Workarounds:
- Make handling idempotent, keyed on
UniqueID, falling back to a hash of the raw body when it is absent. - Serve the endpoint over HTTPS only, so deliveries are harder to capture in the first place.
- Allowlist Formstack's published source IPs at the firewall as defence in depth, never as a replacement for the HMAC. The real deliveries behind this guide came from addresses on that list.
How Hookdeck Can Help: Turn on deduplication and a replayed delivery is collapsed before it reaches your handler, which gives you a second line of defence that does not depend on getting idempotency right in every code path.
The payload shape is different for every form
The Problem: There is no fixed schema to code against. The body keys are the form's own field labels, so a handler written for one form breaks on the next, and renaming a field in the builder silently changes the wire format.
Why It Happens: Formstack posts the submission as answered, and a form's fields are whatever its author made them.
Workarounds:
- Set
postDataFieldKeystofield_idsso the keys are stable numeric IDs that survive a label edit. This also avoids the documented collapse where two fields sharing a label send only the last occurrence. - Fetch the generated per-form schema from
GET /forms/{formId}/webhooks/openapirather than inferring the shape from one sample. - Branch on
FormIDat the top of the handler and keep the per-form field mapping in one place.
How Hookdeck Can Help: Route each FormID to its own destination with Filters, so a single ingestion URL feeds several handlers and one form's field changes cannot break another form's processing.
The digest encoding is not in the current documentation
The Problem: Formstack's live documentation names the X-FS-Signature header and the HMAC Key field but not the algorithm, the encoding or the sha256= prefix, and the developer page that specified them now returns a 404 while still being linked from the help article. That leaves the most precise detail of the most security-sensitive step unstated by the vendor.
Why It Happens: The developer documentation was reorganised around the webhook CRUD API. The current reference confirms that hmacSecret and customHmacHeader exist, which is a description of the settings rather than of the wire format.
Workarounds:
- Implement SHA-256 with lowercase hex and strip the
sha256=prefix. That is what real deliveries use and what Hookdeck's Formstack integration compares against. Base64 does not match, so there is no need to try it. - Capture one real delivery early, from a real submission rather than a constructed test, and keep it as the fixture your verification tests run against.
How Hookdeck Can Help: The request log holds the delivery verbatim, so the first real submission that lands settles the question outright rather than leaving it as an assumption baked into your handler.
Best practices
Verify on the raw body, before parsing
Compute the HMAC over the exact bytes received, respond 401 on mismatch, and only then read the parsed fields. Any middleware that parses first belongs after verification, and for urlencoded deliveries that ordering is the difference between a working integration and one that never verifies.
Read the header name from configuration
X-FS-Signature is a default, not a constant. Read it from an environment variable that falls back to x-fs-signature, lowercased, so that someone filling in the Custom HMAC Header field does not take your endpoint down.
Fail closed on every missing piece
No key configured, no header present, digest mismatch: all three are rejections. Distinguish them in your status codes (500 for your own misconfiguration, 401 for a bad signature) so that an alert tells you which one happened, but never let any of them fall through to acceptance.
Dedupe on UniqueID
With no replay protection in the scheme, idempotency is the mitigation rather than a nicety. Key the work on UniqueID, fall back to a hash of the raw body when it is missing, and make reprocessing the same submission a no-op. See our guide to webhook idempotency.
Acknowledge fast, process asynchronously
Return a 2xx as soon as verification passes and hand the submission to a queue. Formstack publishes no retry policy and no delivery timeout, so the safe assumption is that a slow handler loses submissions with nothing but an error email to show for it. See why to process webhooks asynchronously.
Route on FormID, not on an invented event name
There is one trigger and no event vocabulary. Dispatch on FormID and resist the urge to synthesise an event type, because the next person to read the code will go looking for where it comes from.
Post field IDs when labels can repeat
Switch postDataFieldKeys to field_ids on any form where two fields might share a label. The label-based formats drop all but the last occurrence, and the loss is silent in both the payload and the logs.
Keep the HMAC Key and the API token apart
The HMAC Key is per WebHook and verifies inbound deliveries. The Shared Secret is also per WebHook, but it travels in the body as HandshakeKey and signs nothing. The access token is per account and authenticates your outbound calls to the management API. Storing them in similarly named variables is how a working integration breaks during an unrelated credential rotation.
Conclusion
Formstack Forms webhooks are a per-form submit action with a custom HMAC scheme: HMAC-SHA256 over the raw request body, lowercase hex prefixed sha256=, in X-FS-Signature unless the Custom HMAC Header field renames it. Signing is optional and off by default, so set an HMAC Key first and fail closed second. There are no event types, so route on FormID. There is no replay protection, so dedupe on UniqueID. And the default urlencoded content type means the raw bytes matter more than they would for a JSON-only provider.
Hookdeck Event Gateway verifies the signature on arrival, stores every delivery byte for byte, deduplicates replays, and retries your handler on its own schedule, so the parts Formstack leaves to you stop being things your application code has to get right on the first submission.
Get started with Hookdeck for free and handle Formstack webhooks reliably in minutes.