1. Plugins
  2. Webhook Plugin
Webhook Plugin Icon

Webhook Plugin

Pro

Send each new form result to your server as a JSON payload.

Use the Webhook Plugin to send form results to your own server endpoint as a JSON payload.

💻 This plugin is designed for developers, but a form owner can configure it for them by adding the server endpoint and sharing the Webhook Secret.

# Set Up the Plugin

Open the Plugins page for your form, select Webhook, enter your webhook URL, and click Save:

Configuring the Webhook Plugin with a server endpoint

Once saved, copy the Webhook Secret and store it securely as an environment variable on your server.

# Verify Webhooks

Your server should verify that each webhook request actually came from Fun Forms before processing the data.

The Webhook Plugin sends two headers with every POST request:

  • X-Signature - an HMAC SHA-256 signature generated from the timestamp and raw request body using your webhook secret.
  • X-Signature-Timestamp - the Unix timestamp, in seconds, used when generating the signature.

Your server should recompute the signature using the same secret, timestamp, and raw request body, then compare it with the X-Signature header. If they match, the signature is valid.

# Why the Raw Body Matters

The signature must be calculated from the exact bytes sent by Fun Forms. Parsing the JSON and then converting it back to a string can change whitespace, key order, or encoding, causing the calculated signature to differ.

For this reason, your server needs to capture the raw request body before parsing the JSON.

# Code Example

Here’s a basic example using Express.js to verify a webhook:

import express from 'express';
import crypto from 'crypto';
import bodyParser from 'body-parser';

const app = express();

// Keep the raw request body so we can verify the HMAC.
app.use(
  bodyParser.json({
    verify: (req: any, res, buf) => {
      req.rawBody = buf;
    },
  })
);

app.post('/webhook', (req, res) => {
  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' });
  }

  // Reject requests outside the 5-minute 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' });
  }

  // Recompute the HMAC using the timestamp and raw request body.
  const bodyString = req.rawBody.toString('utf8');
  const signaturePayload = `${timestamp}.${bodyString}`;

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signaturePayload)
    .digest('hex');

  // Compare the signatures using a constant-time comparison.
  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' });
  }

  // The signature is valid, so process the webhook.
  console.log('Valid webhook:', req.body);

  res.json({ success: true });
});

app.listen(3000, () => {
  console.log('Listening on port 3000');
});

# How Verification Works

The example above follows these steps:

  1. Read the headers - Get X-Signature and X-Signature-Timestamp from the request.
  2. Check the timestamp - Reject requests outside the 5-minute window.
  3. Build the signature payload - Concatenate the timestamp, a period (.), and the raw request body.
  4. Calculate the HMAC - Generate an HMAC SHA-256 hash using your webhook secret.
  5. Compare signatures - Use crypto.timingSafeEqual to compare the received and calculated signatures.

If the signatures match and the timestamp is valid, you can process the webhook. Otherwise, reject the request.

Once configured, your webhook URL will receive a JSON payload whenever a new form result is sent.

# JSON Schema

The webhook payload is a Response object and includes the following properties:

export type FunFormsResponse = {
  createdAt: string; // ISO 8601 timestamp when the response was received
  formId: string; // The form's unique ID
  responseId: string; // Unique ID for the response
  data: FunFormsResponseItem[]; // Array of response items
};

export type FunFormsResponseItem = {
  name: string; // The form field's unique name
  label: string; // The form field's label
  value: string | string[]; // String value, or an array for multi-select values
  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 complete example.

# See It in Action

Let’s assume the Webhook Plugin is configured with this form:

An example form connected to the Webhook Plugin

Here’s an example of the JSON payload sent to your webhook endpoint:

{
  "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 response items. Each name is unique within the form and is the recommended way to access an individual response value.

For example, you can flatten the data array into an object keyed by each field’s name:

export const flattenResponse = (data: FunFormsResponseItem[]) =>
  data.reduce(
    (acc, { name, value }) => ({ ...acc, [name]: value }),
    {} as Record<string, string | string[]>
  );

// Usage

const fields = flattenResponse(payload.data);

// "James Smith"
console.log(fields["77485b12-6b6b-4510-9ec7-91db7de88645"]);

Each form field’s name can be customized to a unique string in the Fun Forms form builder. This lets you replace generated identifiers with more human-friendly names if needed.

That’s it! If you need any help, contact us anytime.

Beautiful forms for serious business

From quick enquiries to complex onboarding — signatures, PDFs, conditional logic, unlimited forms and results.

Make Free Forms Arrow Right Icon
  • Check Icon No credit card
  • Check Icon Unlimited forms
  • Check Icon Unlimited results