1. Arrow Left Icon Plugins
Webhook Plugin Icon

Webhook Plugin

Get a JSON payload to your server of each new form response.

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:

Viewing the Webhook Plugin inside Fun Forms

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 HMAC SHA-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 and X-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
  • 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:

An example form we've connected the Webhook Plugin to

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.