---
title: "Webhooks"
description: "Send form submissions to external services, APIs, and your own backend with optional signed webhook verification."
url: https://forminit.com/docs/webhooks/
---

# 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.

---

## Setup

Webhooks are a step in your form's [Workflow](https://forminit.com/docs/workflow/).

1. Open your form in the Forminit dashboard
2. Select **Workflow** in the form header
3. Select **+** where the step should run (beneath **Submission received**, or on an **If / else** branch to send only matching submissions), then choose **Send webhook**
4. Select the step and enter your webhook endpoint URL
5. Optional: copy the step's signing secret and configure it in your receiving application
6. Select **Test**. Forminit sends a validation request to your webhook URL, and the result appears beneath the URL with your endpoint's HTTP status
7. When the test passes, select **Publish**

Your webhook endpoint must return HTTP `200` for the test to pass. A new webhook can't be published until it passes, and changing its URL or signing secret requires another test.

If the endpoint returns a non-`200` response, times out, or cannot be reached, the test fails. Fix the endpoint and select **Test** again.

Once published, Forminit automatically sends a `POST` request to your endpoint URL for every new submission that reaches the step.

---

## Event Types

Forminit currently sends the following production webhook event:

| Event            | Description                                 |
| ---------------- | ------------------------------------------- |
| `form.submitted` | Sent when a new form submission is received |

---

## Webhook Payload

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

### Example Payload

```json
{
  "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"
      }
    }
  }
}
```

---

## Payload Structure

### Top-Level Fields

| Field              | Type   | Description                                                                |
| ------------------ | ------ | -------------------------------------------------------------------------- |
| `event`            | string | Event type. Currently `form.submitted` for production webhooks             |
| `formName`         | string | Name of the form that received the submission                              |
| `id`               | string | Unique submission identifier                                               |
| `submissionStatus` | string | Current status of the submission, such as `Open`, `In Progress`, or `Done` |
| `data`             | object | Contains all submitted form data and metadata                              |

### Data Object

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

| Field               | Type           | Description                                                              |
| ------------------- | -------------- | ------------------------------------------------------------------------ |
| `sender`            | object         | Submitter information such as email, name, phone, or company             |
| `{field-name}`      | varies         | Values from form fields such as text, number, select, radio, or checkbox |
| `file-{name}`       | object / array | File upload data for single or multiple files                            |
| `submissionDate`    | string         | Human-readable submission date                                           |
| `submissionDateISO` | string         | ISO 8601 formatted submission timestamp                                  |
| `submission_info`   | object         | Technical metadata about the submission                                  |

### Sender Object

| Field       | Type   | Description                                           |
| ----------- | ------ | ----------------------------------------------------- |
| `email`     | string | Submitter's email address                             |
| `firstName` | string | Submitter's first name                                |
| `lastName`  | string | Submitter's last name                                 |
| `fullName`  | string | Submitter's full name, if collected as a single field |
| `phone`     | string | Phone number                                          |
| `company`   | string | Company or organization                               |
| `position`  | string | Job title or position                                 |
| `address`   | string | Street address                                        |
| `city`      | string | City name                                             |
| `country`   | string | Country value submitted with the form                 |

### File Object

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

| Field  | Type   | Description                     |
| ------ | ------ | ------------------------------- |
| `file` | string | Public URL to download the file |
| `name` | string | Original filename               |
| `size` | number | File 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.

### Submission Info Object

| Field         | Type   | Description                            |
| ------------- | ------ | -------------------------------------- |
| `ip`          | string | Submitter's IP address                 |
| `user_agent`  | string | Browser user agent string              |
| `referer`     | string | Page URL where the form was submitted  |
| `sdk_version` | string | Forminit SDK version, if using the SDK |
| `location`    | object | Geolocation data derived from IP       |

### Location Object

| Field          | Type   | Description                        |
| -------------- | ------ | ---------------------------------- |
| `country.name` | string | Country name                       |
| `country.iso`  | string | ISO 3166-1 alpha-2 country code    |
| `city.name`    | string | City name                          |
| `geo.lat`      | number | Latitude coordinate                |
| `geo.lng`      | number | Longitude coordinate               |
| `timezone`     | string | IANA timezone identifier           |

---

## Webhook Validation

Before a webhook can be published, Forminit validates the webhook URL from the backend.

The flow is:

1. You enter the webhook URL in the **Send webhook** step
2. You select **Test**
3. Forminit sends a validation request to the webhook URL
4. Your endpoint must return HTTP `200`
5. The result, including your endpoint's HTTP status, appears beneath the URL
6. Once the test passes, you can select **Publish**

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

The validation request includes the same Forminit signature headers used for normal webhook deliveries:

```http
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 test fails and the webhook can't be published.

---

## Signed Webhooks

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.

---

## How Signed Webhooks Work

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

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

### Header Reference

| Header                       | Description                                                                         |
| ---------------------------- | ----------------------------------------------------------------------------------- |
| `Forminit-Webhook-Id`        | Unique ID for the webhook delivery event. Use this to prevent duplicate processing. |
| `Forminit-Webhook-Timestamp` | Unix timestamp in seconds when the webhook request was sent.                         |
| `Forminit-Webhook-Signature` | HMAC-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.

---

## Signing Secret

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

Example:

```text
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:

```bash
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.

---

## Signature Format

Forminit signs the following content:

```text
v1.{webhook_id}.{timestamp}.{raw_request_body}
```

More precisely, the signed content is built from:

1. The UTF-8 bytes of this prefix:

```text
v1.{webhook_id}.{timestamp}.
```

2. 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:

```javascript
hmac.update(`v1.${webhookId}.${timestamp}.`, 'utf8').update(rawBody);
```

```php
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:

```text
v1={hmac_sha256_hex_digest}
```

Example:

```http
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.

---

## Verifying a Webhook Request

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.

---

## Implementation Guarantees and Edge Cases

These details are useful when building your webhook receiver.

### Raw Body Encoding

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.

### Secret Format

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.

### Signature Version

The current signature version is `v1`.

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

```text
v1={64 lowercase hex characters}
```

### Timestamp Tolerance

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.

### File Uploads

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.

### Source IP Allowlisting

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.

---

## Example: Node.js

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

```javascript
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);
```

---

## Example: Express

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

```javascript
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:

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

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

---

## Example: Next.js Route Handler

```typescript
// 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,
  });
}
```

---

## Example: Laravel

Add the secret to your `.env` file:

```bash
FORMINIT_WEBHOOK_SECRET=whsec_your_secret
```

Add it to `config/services.php`:

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

Then add a receiver to `routes/api.php`:

```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,
    ]);
});
```

---

## Example: Python Flask

```python
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)
```

---

## Example: Cloudflare Workers

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

```javascript
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,
    });
  },
};
```

---

## Regenerating the Signing Secret

You can regenerate your webhook signing secret from the **Send webhook** step's settings in your form's Workflow.

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. Select **Test** in the step settings
5. Confirm that your endpoint returns HTTP `200`, then select **Publish**

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.

---

## Retry Policy

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.

---

## Failure Alerts

Forminit can send you an email alert when a webhook delivery fails. This helps you notice broken endpoints early and fix them before more submissions are affected.

A delivery is treated as a failure when your endpoint returns a non-2xx status code, times out, or cannot be reached.

Each failure alert email includes:

- **Error details**: the error response or reason returned by your endpoint, so you can debug the issue
- **Failure count**: how many times the webhook delivery has failed

Failure alerts are useful when your webhook feeds important workflows, such as CRMs, internal tools, databases, support systems, queues, or custom backend processes. Instead of discovering a broken webhook later, you are notified as soon as deliveries start failing.

### Webhook Logs

You can also review failed webhook attempts directly in your dashboard under **Settings → Logs**, or in a submission's **Workflow** section, where you can retry a failed delivery. See [Check results and retry failures](https://forminit.com/docs/workflow/#check-results-and-retry-failures).

The logs show each webhook delivery attempt, making it easy to identify broken endpoints, invalid responses, server errors, or configuration issues.

Combined with the [retry policy](#retry-policy), failure alerts and webhook logs help you fix webhook issues faster and reduce the risk of missed webhook deliveries.

---

## Security Checklist

- 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
