# Formstack Webhooks

Formstack Forms (formstack.com) sends submission data to your endpoint through a
WebHook submit action — labelled "Send Data to an External URL (WebHook)" in the
form builder. One HTTP POST per form submission.

> Scope. This skill is about Formstack Forms only. It does not cover
> Formstack Documents (formerly WebMerge, which has its own separate "Webhook Delivery"
> feature), Formstack Sign (formerly InsureSign), or the Salesforce-packaged products.
> Those are different surfaces with different payloads. A Documents delivery is easy to
> tell apart: it posts `merge_id`, `handshake`, `file_name` and a base64 `file_contents`,
> with a `WebMerge` user-agent and no signature header. A Forms delivery posts
> `FormID` and `UniqueID` with a `FormstackWebhook/1.0` user-agent.

> `X-FS-Signature` is a shared header name, not a shared scheme.
> [FastSpring](/webhooks/skills/fastspring-webhooks)
> — an unrelated e-commerce company — uses the same header name with a base64
> digest and an `events[]` payload envelope. Formstack uses lowercase hex and has
> no envelope. If you are writing `.digest('base64')` or looking for `events`, you
> have the wrong provider.

## When to Use This Skill

* How do I receive Formstack form submissions as a webhook?
* How do I verify the `X-FS-Signature` header from Formstack?
* Why is my Formstack HMAC verification failing on a urlencoded body?
* What event types does Formstack send? (None — see below.)
* What fields will a Formstack webhook POST to my endpoint?
* How do I configure a Formstack WebHook's HMAC Key / Custom HMAC Header?
* What is the Formstack "WebHook Shared Secret" / Handshake Key and should I use it?

## There Are No Event Types

A Formstack WebHook fires on exactly one thing: a form submission.

* There is no event-type header and no event-type body field.
* There is no list of event names and no subscribe-to-events model.
* `form.submitted`, `submission.created`, `form_submission` — none of these exist.
  Do not write code that switches on them.

The only filtering that exists is Routing Logic: a per-WebHook conditional filter on
the submitted answers that decides whether a given submission is sent at all. Via the
v2025 API it is a `logic` object (`action: show|hide`, `conditional: all|any`, and an
array of `{field, condition, option}` checks with `condition` one of `equals`,
`notequals`, `greaterthan`, `lessthan`).

Dispatch on `FormID`, not on an event type. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/overview.md).

## Verification (core)

Signing is optional and off by default. Formstack sends a signature only when the
form owner sets an HMAC Key on that WebHook. Treat verification as required in
production and fail closed: no configured key, or no header → reject.

HMAC-SHA256 over the raw request body bytes, keyed with the WebHook's HMAC Key,
rendered as lowercase hex. Nothing else is signed — no timestamp, no nonce, no URL,
no method. The header is `X-FS-Signature` by default but is user-overridable via the
WebHook's "Custom HMAC Header" field, so read the header name from config.

```javascript
const crypto = require('crypto');

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 (tolerating its absence), 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);
}

```

```python
import hashlib, hmac, re

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.I).strip().lower()
    expected = hmac.new(hmac_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # Compare as BYTES: compare_digest raises TypeError on non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8", "replace"), expected.encode("ascii"))

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/formstack-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/formstack-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/formstack-webhooks/examples/fastapi/).

Confirmed against real deliveries. Formstack's current public documentation states the
header name and the "HMAC Key" field but never names the algorithm or the encoding (the
developer page that did now 404s). The scheme above is therefore confirmed by observation:
two live WebHook deliveries captured on 2026-09-25 carried
`X-FS-Signature: sha256=<64 lowercase hex chars>`, and recomputing HMAC-SHA256 over the raw
urlencoded body with the WebHook's HMAC Key reproduced the digest exactly. The base64 form of
the same MAC does not match. The second delivery was signed after the key was changed and
verified only with the new key. This matches what Hookdeck's `FORMSTACK` source integration
implements. The captured body and signature are a test vector in every example's test suite.
Details in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/verification.md).

## The Raw Body Trap (read this before anything else)

The default content type is `application/x-www-form-urlencoded`, and the digest covers
the raw urlencoded bytes — not a re-encoded form of the parsed dict. Re-encoding a
parsed body reorders keys and re-escapes characters, and the digest will never match.
This is the single most likely place a Formstack implementation goes wrong.

Capture the raw body before parsing, and cover both content types:

```javascript
// Express — the verify hook runs before parsing and hands you the exact bytes.
const saveRaw = (req, res, buf) => { req.rawBody = buf; };
app.post('/webhooks/formstack',
  express.urlencoded({ extended: true, verify: saveRaw }),  // default content type
  express.json({ verify: saveRaw }),                        // if the WebHook is set to JSON
  handler);

```

```typescript
// Next.js — read the text first, then parse it yourself.
const raw = await req.text();
const fields = contentType.includes('json')
  ? JSON.parse(raw)
  : Object.fromEntries(new URLSearchParams(raw));

```

```python
# FastAPI — bytes first, parse second.
raw = await request.body()

```

## Payload Shape Is Per-Form

There is no fixed schema. The body is a flat map of submitted field key → value, and
the keys are the form's own field labels, so the payload differs per form and cannot
be hard-coded.

Key format is configurable per WebHook (`postDataFieldKeys`): `field_names` (default),
`field_ids`, `api_friendly_field_names`, `internal_labels`, `internal_labels_api_friendly`.
A legacy boolean `useFieldIds` is reported back on the webhook object but is not settable
through the v2025 API — use `postDataFieldKeys: field_ids`.

Documented footgun: with `field_names` (default) or `api_friendly_field_names`,
duplicate labels collapse. The help article: if two fields share a label, or a repeated
field type is used, only the last occurrence is sent. Two `Full Name:` fields holding
"Jane Doe" and "John Doe" arrive as a single value, "John Doe". Use field IDs when labels
may repeat.

To find out exactly what a given form will send, ask Formstack:

```
GET https://www.formstack.com/api/v2025/forms/{formId}/webhooks/openapi

```

It returns a generated OpenAPI schema for that form's webhook payload.

### Metadata keys

The API reference's own example webhook schema shows `FormID` and `UniqueID` (both
strings) alongside the form's field keys:

```json
{ "FormID": "1234567", "UniqueID": "9876543210", "Name": "Jane Smith", "Email": "jane@example.com" }

```

Real deliveries confirm both, as strings, ahead of the field keys. A delivery from a form
with no answer fields, captured on the wire, was exactly:

```
FormID=6606394&UniqueID=1500878955&HandshakeKey=test

```

`HandshakeKey` carries the WebHook's Shared Secret (see below). Both captures had one set, so
whether the field is omitted or sent empty without one is unobserved. Treat `FormID` as
the routing key and `UniqueID` as the idempotency key, and read every key defensively. There
is no `Timestamp`, `FormName` or `SubmissionID` field; don't depend on one.

## Shared Secret (Handshake Key) — Weaker, Not a Signature

The WebHook settings also offer a "WebHook Shared Secret", which Formstack's help centre
also calls a Handshake Key — "an additional value you can send to your WebHook Endpoint
that you may use to verify that the data is trustworthy, or at the very least that the data
is being sent from Formstack." It is `sharedSecret` on the v2025 API object.

It is a static bearer-style token with no per-request binding — it proves origin only as
well as any constant does, and it is replayable and loggable. Prefer the HMAC Key. Use
the shared secret only if your endpoint already requires such a token.

It arrives as a body field named `HandshakeKey`, after `FormID` and `UniqueID`. Formstack's
documentation does not name it; this is observed from real deliveries. A delivery made after
the HMAC Key was changed still carried the unchanged Shared Secret in `HandshakeKey`, so the
field carries the Shared Secret and the HMAC Key is never sent. Because it is in the body,
it is inside the signed content and lands wherever you log request bodies. If you use it,
compare it constant-time, and strip it before storing or forwarding the submission.

## Replay

There is no timestamp and no nonce in the signed content, so a captured Formstack
delivery is replayable indefinitely. A staleness window is impossible here — don't
invent one. Instead:

* Make handling idempotent, keyed on `UniqueID` (falling back to a hash of the raw body).
* Serve the endpoint over HTTPS only.

## Environment Variables

```bash
# The per-WebHook "HMAC Key" from the form's WebHook submit action settings.
# NOT an API client secret, access token, or Personal Access Token.
# Each WebHook on each form can have its own key.
FORMSTACK_HMAC_KEY=your_webhook_hmac_key

# Header carrying the digest. Defaults to x-fs-signature; override only if the
# WebHook's "Custom HMAC Header" field is set. Lowercase — frameworks lowercase headers.
FORMSTACK_SIGNATURE_HEADER=x-fs-signature

```

## Delivery

Only what's documented:

* Error Handling / `errorEmails` — comma-separated addresses notified when delivery
  fails.
* An HMAC Key plus at least one error email is required before Formstack will send full
  credit-card data over a webhook (there's a separate PCI acknowledgement, exposed on the
  API as `customerApprovedPciCompliant`).
* Source IPs are published for firewall allowlisting: `52.71.30.102`, `3.227.148.190`,
  `44.196.66.47`, `54.69.216.81`, `52.37.95.20`, `52.24.103.36`. These are a firewall aid
  Formstack can change without notice — never a substitute for the HMAC.
* `status.formstack.com` publishes incidents.

Observed on real deliveries, but not documented: a `User-Agent` of
`FormstackWebhook/1.0 (Form <FormID>)`, which names the form before you parse the body.
Route on the `FormID` body field anyway, since the user-agent format is not a published
contract. The other headers were `Content-Type`, `Content-Length`, the signature header and
Datadog tracing headers (`tracestate`, `x-datadog-*`).

Not documented, and not observed, so don't assert it: retry counts, retry backoff,
delivery timeouts, or a delivery-id or request-id header. Return 2xx quickly and process
asynchronously regardless.

## Setup in One Minute

UI: Form Settings → Emails & Actions → Advance Settings → Add Webhook. Set the URL,
Content Type, field-key format, and — the important first step — an HMAC Key. Without
one, Formstack sends no signature at all.

API (v2025, base `https://www.formstack.com/api/v2025`):

```bash
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"
  }'

```

That API is authenticated with OAuth2 / a Personal Access Token, which is not the
webhook HMAC key. Keep the two separate. Full field list in
[references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/setup.md).

## Local Development

```bash
npx hookdeck-cli listen 3000 formstack --path /webhooks/formstack

```

No account required — the CLI creates a guest account on first run and gives you a public
HTTPS URL plus a web UI for inspecting requests. Paste the printed URL into the WebHook's
URL Address field, set an HMAC Key, and submit the form to see a real delivery land.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/overview.md) - Why there are no event types, payload shape, per-form schemas, routing on FormID
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/setup.md) - UI walkthrough, every v2025 API field and enum, PCI requirement, Routing Logic
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/formstack-webhooks/references/verification.md) - HMAC details, the urlencoded raw-body trap, prefix tolerance, debugging