Skip to content

Webhooks

Webhooks allow you to automatically send form submission data to external services, APIs, or your own backend whenever a new submission is received.

Use cases: CRM integrations, custom workflows, database syncing, internal tools, automation, and more.


  1. Go to your Form Settings
  2. Navigate to ActionsWebhook
  3. Enter your webhook endpoint URL
  4. Click Save
  5. Optional: copy your signing secret and configure it in your receiving application
  6. Click Test & Save
  7. Forminit sends a validation request to your webhook URL
  8. If your endpoint returns HTTP 200, the webhook is saved and activated

Your webhook endpoint must return HTTP 200 for the Test & Save step to succeed.

If the endpoint returns a non-200 response, times out, or cannot be reached, Forminit will not activate the webhook. Fix the endpoint and try Test & Save again.

Once activated, Forminit will automatically send a POST request to your endpoint URL whenever a new form submission is received.


Forminit currently sends the following production webhook event:

EventDescription
form.submittedSent when a new form submission is received

When a form is submitted, Forminit sends a POST request to your webhook URL with a JSON payload containing the submission data.

{
  "event": "form.submitted",
  "formName": "contact-form",
  "id": "xK9mLpQr2vNtYw8z",
  "submissionStatus": "Open",
  "data": {
    "message": "I'm interested in learning more about your enterprise solutions.",
    "sender": {
      "email": "james.wilson@example.co.uk",
      "firstName": "James",
      "lastName": "Wilson",
      "phone": "+442071234567",
      "company": "Acme Ltd",
      "position": "Product Manager"
    },
    "plan": "Enterprise",
    "budget": 50000,
    "preferred-date": "2026-02-15",
    "file-proposal": {
      "file": "https://files.forminit.com/abc123/project-proposal.pdf",
      "size": 245000,
      "name": "project-proposal.pdf"
    },
    "file-attachments": [
      {
        "file": "https://files.forminit.com/abc123/requirements.pdf",
        "size": 128400,
        "name": "requirements.pdf"
      },
      {
        "file": "https://files.forminit.com/abc123/timeline.xlsx",
        "size": 45200,
        "name": "timeline.xlsx"
      }
    ],
    "submissionDate": "05-01-2026 14:30",
    "submissionDateISO": "2026-01-05T14:30:00+00:00",
    "submission_info": {
      "ip": "203.0.113.42",
      "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
      "referer": "https://example.co.uk/contact",
      "sdk_version": "0.2.1",
      "location": {
        "country": {
          "name": "United Kingdom",
          "iso": "GB"
        },
        "city": {
          "name": "London"
        },
        "geo": {
          "lat": 51.5074,
          "lng": -0.1278
        },
        "timezone": "Europe/London"
      }
    }
  }
}

FieldTypeDescription
eventstringEvent type. Currently form.submitted for production webhooks
formNamestringName of the form that received the submission
idstringUnique submission identifier
submissionStatusstringCurrent status of the submission, such as Open, In Progress, or Done
dataobjectContains all submitted form data and metadata

The data object contains all form field values using their field names as keys.

FieldTypeDescription
senderobjectSubmitter information such as email, name, phone, or company
{field-name}variesValues from form fields such as text, number, select, radio, or checkbox
file-{name}object / arrayFile upload data for single or multiple files
submissionDatestringHuman-readable submission date
submissionDateISOstringISO 8601 formatted submission timestamp
submission_infoobjectTechnical metadata about the submission
FieldTypeDescription
emailstringSubmitter’s email address
firstNamestringSubmitter’s first name
lastNamestringSubmitter’s last name
fullNamestringSubmitter’s full name, if collected as a single field
phonestringPhone number
companystringCompany or organization
positionstringJob title or position
addressstringStreet address
citystringCity name
countrystringCountry value submitted with the form

Single file uploads return an object. Multiple file uploads return an array of objects.

FieldTypeDescription
filestringPublic URL to download the file
namestringOriginal filename
sizenumberFile size in bytes

Uploaded files are not sent as binary content in the webhook request body.

File fields contain file metadata and file URLs. The webhook signature covers the JSON webhook body, including those file URLs, not the binary file content itself.

FieldTypeDescription
ipstringSubmitter’s IP address
user_agentstringBrowser user agent string
refererstringPage URL where the form was submitted
sdk_versionstringForminit SDK version, if using the SDK
locationobjectGeolocation data derived from IP
FieldTypeDescription
country.namestringCountry name
country.isostringISO 3166-1 alpha-2 country code
city.namestringCity name
geo.latnumberLatitude coordinate
geo.lngnumberLongitude coordinate
timezonestringIANA timezone identifier

Before a webhook is activated, Forminit validates the webhook URL from the backend.

The flow is:

  1. You enter the webhook URL
  2. You click Save
  3. Forminit stores the webhook URL
  4. You click Test & Save
  5. Forminit sends a validation request to the saved webhook URL
  6. Your endpoint must return HTTP 200
  7. If the response is 200, Forminit activates the webhook

The validation request is sent by Forminit’s backend, not from your browser.

This is important because:

  • The frontend should not generate webhook signatures
  • Browser CORS rules may block direct webhook tests
  • Validation and production deliveries should use the same backend delivery path
  • Timeout, retry, and logging behaviour should stay consistent

If signed webhooks are enabled, the validation request includes the same Forminit signature headers used for normal webhook deliveries:

Forminit-Webhook-Id: wh_01KVTW54BTA7A86N94MGHSBCHY
Forminit-Webhook-Timestamp: 1782239629
Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81

Your endpoint should verify the signature and return HTTP 200 when the request is accepted.

If your endpoint returns anything other than HTTP 200, the webhook will not be activated.


Signed webhooks help your server verify that an incoming webhook request was sent by Forminit and that the request body was not changed after it was signed.

This feature is optional. Existing webhook receivers can continue to accept Forminit webhook requests without verifying the signature.

If you need stronger security for your webhook endpoint, store the signing secret in your application and verify each request before processing the payload.

Signed webhooks apply to generic Forminit webhooks. Slack and Discord webhook notifications are not affected.


When a signing secret is available for your generic webhook, Forminit adds these headers to every webhook request:

Forminit-Webhook-Id: wh_01KVTW54BTA7A86N94MGHSBCHY
Forminit-Webhook-Timestamp: 1782239629
Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81
HeaderDescription
Forminit-Webhook-IdUnique ID for the webhook delivery event. Use this to prevent duplicate processing.
Forminit-Webhook-TimestampUnix timestamp in seconds when the webhook request was sent.
Forminit-Webhook-SignatureHMAC-SHA256 signature of the webhook request. The current version is v1.

The webhook ID is different from the submission ID in the payload.

  • id in the payload identifies the form submission.
  • Forminit-Webhook-Id identifies the webhook delivery event.

This distinction is useful when Forminit retries the same webhook event.


The signing secret is a private key shared between Forminit and your webhook receiver.

Example:

whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Use the complete whsec_... value as your HMAC key.

Do not remove the whsec_ prefix. Do not Base64 decode the secret. Treat it as a normal UTF-8 string.

Store the secret in an environment variable or secret manager:

FORMINIT_WEBHOOK_SECRET=whsec_your_secret

Do not hard-code the secret in your source code, expose it in frontend JavaScript, or send it back in a webhook response.


Forminit signs the following content:

v1.{webhook_id}.{timestamp}.{raw_request_body}

More precisely, the signed content is built from:

  1. The UTF-8 bytes of this prefix:
v1.{webhook_id}.{timestamp}.
  1. Followed by the exact raw request body bytes sent by Forminit.

Forminit webhook bodies are JSON and are sent as UTF-8.

This means these two implementation styles are equivalent for Forminit webhook requests:

hmac.update(`v1.${webhookId}.${timestamp}.`, 'utf8').update(rawBody);
hash_hmac('sha256', "v1.{$webhookId}.{$timestamp}.{$rawBody}", $secret);

The safest approach is to verify the exact raw request body bytes before parsing JSON.

Forminit then creates an HMAC-SHA256 digest using your webhook signing secret.

The final signature header uses this format:

v1={hmac_sha256_hex_digest}

Example:

Forminit-Webhook-Signature: v1=a6f4cf2fae5065c5216bb209a60ca6c3066eb354cf12f74d8c400d7c108e7b81

The signature value is lowercase hexadecimal.

Forminit sends a single v1 signature. Multiple signatures are not sent during secret regeneration because the previous secret becomes invalid immediately.


Your webhook endpoint should verify the signature before parsing JSON or creating side effects.

Recommended verification flow:

  1. Read the raw request body before JSON parsing.
  2. Read the Forminit-Webhook-Id, Forminit-Webhook-Timestamp, and Forminit-Webhook-Signature headers.
  3. Reject the request if any required header is missing.
  4. Reject timestamps older or newer than five minutes.
  5. Build the signed content as v1.{webhook_id}.{timestamp}. followed by the raw request body bytes.
  6. Calculate the HMAC-SHA256 signature with your signing secret.
  7. Compare the expected signature with the received signature using a constant-time comparison.
  8. Check whether the webhook ID was already processed.
  9. Parse the JSON payload and process the event.

The raw body matters. If your framework parses and re-serializes the JSON body before verification, the signature may not match.


These details are useful when building your webhook receiver.

Webhook request bodies are JSON encoded as UTF-8.

Always verify the raw request body before parsing or re-serializing JSON. Even small formatting changes, such as whitespace, escaping, or key order, can change the signature.

Use the complete whsec_... value as the HMAC-SHA256 key.

Do not remove the whsec_ prefix. Do not Base64 decode the secret. Treat it as a normal UTF-8 string.

The current signature version is v1.

Your receiver should check that the signature header matches this format:

v1={64 lowercase hex characters}

The recommended timestamp tolerance is five minutes.

Rejecting old timestamps helps reduce replay risk, but timestamp validation does not replace idempotency. You should still store processed Forminit-Webhook-Id values if your webhook creates side effects.

Uploaded files are not sent as binary content in the webhook request body.

File fields contain file metadata and file URLs. The signature covers the JSON webhook body, including those file URLs, not the binary file content itself.

Signature verification is the recommended way to verify webhook authenticity.

Forminit does not currently document fixed webhook source IP ranges for allowlisting. If you use firewall rules, keep signature verification enabled as the primary trust mechanism.


This example uses only built-in Node.js modules.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const secret = process.env.FORMINIT_WEBHOOK_SECRET;

if (!secret) {
  throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}

function verifyForminitWebhook(rawBody, headers) {
  const webhookId = headers['forminit-webhook-id'] ?? '';
  const timestamp = headers['forminit-webhook-timestamp'] ?? '';
  const signature = headers['forminit-webhook-signature'] ?? '';
  const match = signature.match(/^v1=([a-f0-9]{64})$/);

  if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
    return null;
  }

  if (!/^\d+$/.test(timestamp)) {
    return null;
  }

  if (!match) {
    return null;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));

  if (age > 300) {
    return null;
  }

  const expected = createHmac('sha256', secret)
    .update(`v1.${webhookId}.${timestamp}.`, 'utf8')
    .update(rawBody)
    .digest();

  const received = Buffer.from(match[1], 'hex');

  if (expected.length !== received.length) {
    return null;
  }

  return timingSafeEqual(expected, received) ? webhookId : null;
}

createServer(async (request, response) => {
  if (request.method !== 'POST' || request.url !== '/webhooks/forminit') {
    response.writeHead(404).end();
    return;
  }

  const chunks = [];

  for await (const chunk of request) {
    chunks.push(chunk);
  }

  const rawBody = Buffer.concat(chunks);
  const webhookId = verifyForminitWebhook(rawBody, request.headers);

  if (!webhookId) {
    response.writeHead(401, { 'Content-Type': 'application/json' });
    response.end(JSON.stringify({ error: 'Invalid webhook.' }));
    return;
  }

  const payload = JSON.parse(rawBody.toString('utf8'));

  if (payload.event === 'form.submitted') {
    // Process the verified submission here.
  }

  response.writeHead(200, { 'Content-Type': 'application/json' });
  response.end(JSON.stringify({ received: true, webhook_id: webhookId }));
}).listen(3000);

Use express.raw() on the webhook route. express.json() must not parse this request before signature verification.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.FORMINIT_WEBHOOK_SECRET;

if (!secret) {
  throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
}

function verifyForminitWebhook(rawBody, request) {
  const webhookId = request.get('Forminit-Webhook-Id') ?? '';
  const timestamp = request.get('Forminit-Webhook-Timestamp') ?? '';
  const signatureHeader = request.get('Forminit-Webhook-Signature') ?? '';
  const match = signatureHeader.match(/^v1=([a-f0-9]{64})$/);

  if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
    return null;
  }

  if (!/^\d+$/.test(timestamp)) {
    return null;
  }

  if (!match) {
    return null;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));

  if (age > 300) {
    return null;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`v1.${webhookId}.${timestamp}.`, 'utf8')
    .update(rawBody)
    .digest();

  const received = Buffer.from(match[1], 'hex');

  if (expected.length !== received.length) {
    return null;
  }

  return crypto.timingSafeEqual(expected, received) ? webhookId : null;
}

app.post(
  '/webhooks/forminit',
  express.raw({ type: 'application/json' }),
  (request, response) => {
    const rawBody = request.body;
    const webhookId = verifyForminitWebhook(rawBody, request);

    if (!webhookId) {
      return response.status(401).json({ error: 'Invalid webhook.' });
    }

    const payload = JSON.parse(rawBody.toString('utf8'));

    if (payload.event === 'form.submitted') {
      // Process the verified submission here.
    }

    return response.json({
      received: true,
      webhook_id: webhookId,
    });
  },
);

// Add express.json() after the webhook route if needed for other routes.
app.use(express.json());

app.listen(3000);

If your app expects larger JSON webhook bodies, you can set your own body limit:

express.raw({ type: 'application/json', limit: '1mb' });

The limit above is an application-level Express setting, not a Forminit webhook payload limit.


// app/api/webhooks/forminit/route.ts
import crypto from 'node:crypto';
import { NextResponse } from 'next/server';

const secret = process.env.FORMINIT_WEBHOOK_SECRET;

function verifyForminitWebhook(
  rawBody: Buffer,
  headers: Headers,
): string | null {
  if (!secret) {
    throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
  }

  const webhookId = headers.get('Forminit-Webhook-Id') ?? '';
  const timestamp = headers.get('Forminit-Webhook-Timestamp') ?? '';
  const signatureHeader = headers.get('Forminit-Webhook-Signature') ?? '';
  const match = signatureHeader.match(/^v1=([a-f0-9]{64})$/);

  if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
    return null;
  }

  if (!/^\d+$/.test(timestamp)) {
    return null;
  }

  if (!match) {
    return null;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));

  if (age > 300) {
    return null;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`v1.${webhookId}.${timestamp}.`, 'utf8')
    .update(rawBody)
    .digest();

  const received = Buffer.from(match[1], 'hex');

  if (expected.length !== received.length) {
    return null;
  }

  return crypto.timingSafeEqual(expected, received) ? webhookId : null;
}

export async function POST(request: Request) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const webhookId = verifyForminitWebhook(rawBody, request.headers);

  if (!webhookId) {
    return NextResponse.json(
      { error: 'Invalid webhook.' },
      { status: 401 },
    );
  }

  const payload = JSON.parse(rawBody.toString('utf8'));

  if (payload.event === 'form.submitted') {
    // Process the verified submission here.
  }

  return NextResponse.json({
    received: true,
    webhook_id: webhookId,
  });
}

Add the secret to your .env file:

FORMINIT_WEBHOOK_SECRET=whsec_your_secret

Add it to config/services.php:

'forminit' => [
    'webhook_secret' => env('FORMINIT_WEBHOOK_SECRET'),
],

Then add a receiver to routes/api.php:

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/forminit', function (Request $request) {
    $rawBody = $request->getContent();
    $webhookId = (string) $request->header('Forminit-Webhook-Id', '');
    $timestamp = (string) $request->header('Forminit-Webhook-Timestamp', '');
    $signature = (string) $request->header('Forminit-Webhook-Signature', '');
    $secret = (string) config('services.forminit.webhook_secret');

    if ($secret === '') {
        return response()->json(['error' => 'Webhook secret is not configured.'], 500);
    }

    if (
        !preg_match('/^wh_[A-Za-z0-9_-]+$/', $webhookId)
        || !ctype_digit($timestamp)
        || !preg_match('/^v1=([a-f0-9]{64})$/', $signature, $matches)
    ) {
        return response()->json(['error' => 'Malformed webhook headers.'], 400);
    }

    if (abs(time() - (int) $timestamp) > 300) {
        return response()->json(['error' => 'Webhook timestamp expired.'], 401);
    }

    $expected = hash_hmac(
        'sha256',
        "v1.{$webhookId}.{$timestamp}.{$rawBody}",
        $secret
    );

    if (!hash_equals($expected, $matches[1])) {
        return response()->json(['error' => 'Invalid webhook signature.'], 401);
    }

    try {
        $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        return response()->json(['error' => 'Invalid JSON body.'], 400);
    }

    if (($payload['event'] ?? null) === 'form.submitted') {
        // Process the verified submission here.
    }

    return response()->json([
        'received' => true,
        'webhook_id' => $webhookId,
    ]);
});

import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, jsonify, request

app = Flask(__name__)
secret = os.environ["FORMINIT_WEBHOOK_SECRET"]


def verify_forminit_webhook(raw_body):
    webhook_id = request.headers.get("Forminit-Webhook-Id", "")
    timestamp = request.headers.get("Forminit-Webhook-Timestamp", "")
    signature = request.headers.get("Forminit-Webhook-Signature", "")
    signature_match = re.fullmatch(r"v1=([a-f0-9]{64})", signature)

    if not re.fullmatch(r"wh_[A-Za-z0-9_-]+", webhook_id):
        return None

    if not timestamp.isdigit() or signature_match is None:
        return None

    if abs(int(time.time()) - int(timestamp)) > 300:
        return None

    signed_content = f"v1.{webhook_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(
        secret.encode(),
        signed_content,
        hashlib.sha256,
    ).hexdigest()

    return webhook_id if hmac.compare_digest(expected, signature_match.group(1)) else None


@app.post("/webhooks/forminit")
def receive_forminit_webhook():
    raw_body = request.get_data(cache=False, as_text=False)
    webhook_id = verify_forminit_webhook(raw_body)

    if not webhook_id:
        return jsonify(error="Invalid webhook."), 401

    try:
        payload = json.loads(raw_body)
    except (UnicodeDecodeError, json.JSONDecodeError):
        return jsonify(error="Invalid JSON body."), 400

    if payload.get("event") == "form.submitted":
        # Process the verified submission here.
        pass

    return jsonify(received=True, webhook_id=webhook_id)

This example works in server-side Web Fetch API environments such as Cloudflare Workers. Store the secret in the FORMINIT_WEBHOOK_SECRET binding.

const encoder = new TextEncoder();

function hexToBytes(hex) {
  return Uint8Array.from(hex.match(/.{2}/g), (byte) => Number.parseInt(byte, 16));
}

function joinBytes(left, right) {
  const output = new Uint8Array(left.length + right.length);
  output.set(left);
  output.set(right, left.length);
  return output;
}

async function verifyForminitWebhook(rawBody, headers, secret) {
  if (!secret) {
    throw new Error('FORMINIT_WEBHOOK_SECRET is required.');
  }

  const webhookId = headers.get('Forminit-Webhook-Id') ?? '';
  const timestamp = headers.get('Forminit-Webhook-Timestamp') ?? '';
  const signature = headers.get('Forminit-Webhook-Signature') ?? '';
  const match = signature.match(/^v1=([a-f0-9]{64})$/);

  if (!/^wh_[A-Za-z0-9_-]+$/.test(webhookId)) {
    return null;
  }

  if (!/^\d+$/.test(timestamp)) {
    return null;
  }

  if (!match) {
    return null;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));

  if (age > 300) {
    return null;
  }

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  );

  const prefix = encoder.encode(`v1.${webhookId}.${timestamp}.`);
  const valid = await crypto.subtle.verify(
    'HMAC',
    key,
    hexToBytes(match[1]),
    joinBytes(prefix, rawBody),
  );

  return valid ? webhookId : null;
}

export default {
  async fetch(request, env) {
    const rawBody = new Uint8Array(await request.arrayBuffer());

    const webhookId = await verifyForminitWebhook(
      rawBody,
      request.headers,
      env.FORMINIT_WEBHOOK_SECRET,
    );

    if (!webhookId) {
      return Response.json({ error: 'Invalid webhook.' }, { status: 401 });
    }

    const payload = JSON.parse(new TextDecoder().decode(rawBody));

    if (payload.event === 'form.submitted') {
      // Process the verified submission here.
    }

    return Response.json({
      received: true,
      webhook_id: webhookId,
    });
  },
};

You can regenerate your webhook signing secret from the webhook settings.

Regenerating the secret immediately invalidates the previous secret. Webhook deliveries may fail until the new secret is configured in your receiving application.

After regenerating the secret:

  1. Copy the new signing secret from Forminit
  2. Update FORMINIT_WEBHOOK_SECRET in your receiving application
  3. Redeploy or restart your application if required
  4. Click Test & Save
  5. Confirm that your endpoint returns HTTP 200

There is no grace period. The previous secret stops working immediately.

Forminit sends a single v1 signature with each webhook request. It does not send multiple signatures for old and new secrets during rotation.


If your endpoint returns a non-2xx status code or times out, Forminit will retry the webhook delivery with exponential backoff.

For retries of the same webhook event:

  • Forminit-Webhook-Id remains the same
  • Forminit-Webhook-Timestamp is updated
  • Forminit-Webhook-Signature is recalculated
  • The request body remains the same

Use Forminit-Webhook-Id to make your webhook receiver idempotent.

For example, if your endpoint already processed a webhook ID, return a 2xx response without processing it again. This prevents duplicate side effects such as creating the same record twice, sending the same email twice, or running the same workflow twice.

Return a 2xx response as soon as possible. If your webhook needs to perform slow work, such as calling another API, sending emails, or processing files, save the verified event first and move the slow work to a queue.

Webhook timeout and retry-attempt limits may depend on Forminit’s delivery configuration. If you need strict delivery timing guarantees for your own infrastructure, design your endpoint to acknowledge verified webhook requests quickly.


  • Store the signing secret in an environment variable or secret manager
  • Never expose the signing secret in frontend code
  • Never send the signing secret back in a webhook response
  • Verify the raw request body before parsing JSON
  • Reject timestamps outside the five-minute tolerance
  • Use a constant-time comparison for signatures
  • Store processed Forminit-Webhook-Id values to avoid duplicate processing
  • Return a 2xx response quickly and move slow work to a queue
  • Do not log the signing secret, signature header, or full request body
  • Do not create side effects before signature verification