Use the Webhook Plugin
to send form results to your own server endpoint, as a JSON
payload.
💻 This plugin is for developers, however as the form owner you can easily set it up for them.
Setup the Plugin
Go to the Plugins page in your form, select Webhook and enter your webhook URL, hit Save:

You’ll then need to copy the Webhook Secret and store it as an environment variable.
Verifying Webhooks
When your server receives a webhook (via a POST
request), you need to make sure it really came from Fun Forms and not from a random attacker. To do this we send two headers with every webhook request:
X-Signature
: a HMACSHA-256
hash of the request body + timestamp, signed with your webhook secret.X-Signature-Timestamp
: a Unix timestamp (seconds).
Your server recomputes the signature using the secret and the raw request body, then compares it with the one we sent. If they match, the webhook is valid.
Why Raw Body?
It’s critical to use the exact bytes of the request body when calculating the HMAC. If you parse and re-stringify JSON, you can accidentally change whitespace, key order, or encoding — which will make the signature fail. That’s why in the Express example below, we capture the raw request body before parsing.
Code Example
Here’s a barebones example using Express.js
to verify your webhook came from Fun Forms:
import express from 'express';
import crypto from 'crypto';
import bodyParser from 'body-parser';
const app = express();
// Use raw body so we can compute HMAC correctly
app.use(
bodyParser.json({
verify: (req: any, res, buf) => {
req.rawBody = buf; // keep raw bytes
},
})
);
app.post('/webhook', (req, res) => {
// Get headers
const signature = req.get('X-Signature');
const timestampHeader = req.get('X-Signature-Timestamp');
const secret = process.env.WEBHOOK_SECRET;
if (!signature || !timestampHeader || !secret) {
return res.status(400).json({ error: 'Missing signature or timestamp' });
}
// Check timestamp (5 min window)
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return res.status(400).json({ error: 'Invalid or expired timestamp' });
}
// Verify HMAC signature
const bodyString = req.rawBody.toString('utf8');
const signaturePayload = `${timestamp}.${bodyString}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signaturePayload)
.digest('hex');
// Constant-time compare (safe against timing attacks)
const sigBuf = Buffer.from(signature, 'hex');
const expectedBuf = Buffer.from(expectedSignature, 'hex');
if (
sigBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(sigBuf, expectedBuf)
) {
return res.status(400).json({ error: 'Invalid signature' });
}
// ✅ Signature valid, handle webhook
console.log('Valid webhook:', req.body);
res.json({ success: true });
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
Step-by-step
- Read headers: grab
X-Signature
andX-Signature-Timestamp
- Check timestamp: reject if it’s older/newer than 5 minutes
- Recompute HMAC:
- Concatenate
timestamp + '.' + rawBody
- Hash with
SHA-256
using your secret key
- Concatenate
- Compare signatures: use
crypto.timingSafeEqual
to avoid timing attacks
If valid, trust the webhook and process it. Otherwise, reject with 400
.
Now when form results are sent, your Webhook URL will receive a JSON
payload containing the form response.
JSON Schema
The JSON payload is of type Response
and always includes the following properties:
export type FunFormsResponse = {
createdAt: string; // ISO 8601 datestamp when the response was received
formId: string; // The form's unique ID that captured the submission
responseId: string; // The response ID, unique every time
data: FunFormsResponseItem[]; // an array of `FunFormsResponseItem` types
}
export type FunFormsResponseItem = {
name: string; // the form field's unique name
label: string; // the form field's label
value: string | string[]; // string value unless multi-select (checkbox)
type: (
| 'text' // Short Answer field
| 'textarea' // Long Answer field
| 'fullname' // Full Name field
| 'radio' // Single-Select field
| 'checkbox' // Multi-Select field
| 'select' // Dropdown field
| 'email' // Email field
| 'number' // Number field
| 'url' // URL field
| 'date' // Date field
| 'time' // Time field
| 'tel' // Telephone field
| 'consent' // Consent field
| 'upload' // Upload field
| 'signature' // Signature field
| 'yesno' // Yes + No field
| 'address' // Address field
| 'matrix' // Matrix field
| 'rating' // Rating field
| 'scale' // Scale field
| 'hidden' // Hidden field
)
}
See below for a full JSON example for further clarification.
Plugin Demo
Let’s assume we’ve setup the endpoint for the Webhook Plugin and have this form:

Here’s an example of the webhook JSON payload:
{
"createdAt": "2025-05-06T17:45:31+01:00",
"formId": "RHgUnfcG",
"responseId": "VdN2GDOcS6el7Be433N2",
"data": [
{
"name": "77485b12-6b6b-4510-9ec7-91db7de88645",
"label": "Name",
"type": "fullname",
"value": "James Smith"
},
{
"name": "967e3e6b-aa76-49d0-a2e9-675a25364865",
"label": "Please rate the event",
"type": "rating",
"value": "5"
},
{
"name": "79d3660d-a448-4470-86c3-80dbfb28418a",
"label": "How likely would you recommend the event?",
"type": "scale",
"value": "9"
}
]
}
The data
property contains an array of your response with each name
attribute being unique and the recommended way to access response data. It’s logical to flatten this data
response into a simple object for easy referencing:
export const flattenResponse = (data: FunFormsResponseItem) => data.reduce(
(acc, { name, value }) => ({ ...acc, [name]: value }),
{}
);
// Usage
const fields = flattenResponse(payload.data);
// "James Smith"
console.log(fields["77485b12-6b6b-4510-9ec7-91db7de88645"]);
Each form field’s name
attribute can be customized to a unique string within the Fun Forms form builder if you wish to create more human-friendly identifiers.
That’s it! You can contact us for help anytime.