# Guide to Form.io Webhooks: Features and Best Practices

Form.io is a platform for building forms and the APIs behind them. Its Webhook action is how a form tells another system that someone submitted, edited or deleted an entry, so the data can land in a CRM, a database, a ticketing system or your own service as soon as it is saved.

This guide covers what the Webhook action sends and with which HTTP method, how to authenticate it with Basic credentials, the settings that change its behaviour, and the practices that keep a Form.io receiver reliable in production.

## What are Form.io webhooks?

A Form.io webhook is a form action. You add a **Webhook** action to a form or resource under **Actions**, give it a URL, and Form.io calls that URL with the submission data whenever the action fires. By default the action fires on create, update and delete.

Two details shape every receiver. First, there is no event field. With the default **Match** request method, Form.io uses the method of the request that triggered the action, so a new submission arrives as `POST`, an edit as `PUT` and a deletion as `DELETE`. The HTTP method is how you tell them apart. Second, webhooks only run when a form is used, through the FormviewPro application or a form embedded in an app. Building or editing a form in the Developer Portal fires nothing.

Form.io does not sign requests. If you fill in **Authorize User** and **Authorize Password**, it sends them as an `Authorization: Basic` header on each request.

## Form.io webhook features

| Feature | Details |
| --- | --- |
| Configuration | A **Webhook** action on a form or resource, under **Actions** |
| Fires on | Submission create, update and delete |
| Request method | **Match** (default) or a fixed Get, Post, Put, Delete or Patch |
| With Match | Create is `POST`, update is `PUT`, delete is `DELETE` |
| Body | `{ request, submission, params }` by default, or the output of **Transform Payload** |
| Authentication | **Authorize User** and **Authorize Password** (HTTP Basic), or **Additional Headers**. No signature |
| Forward Headers | Optional. Passes the headers the Form.io server received through to your endpoint |
| Wait for Webhook Response | Optional. The submission waits for your response and shows errors to the form user |
| Retries | Opt-in. Constant, linear, exponential, jitter or exponential jitter, with a maximum number of attempts and an initial delay |
| Timeout and source IPs | Not documented. The endpoint must be reachable from the Form.io deployment's network |

## What a request looks like

The default body is a JSON object with the request the Form.io server received, the saved submission, and the URL parameters. Abridged from Form.io's sample, for a form with a text field and a number field:

```json
{
  "request": {
    "data": { "textField": "Test value", "number": 12345, "submit": true },
    "state": "submitted",
    "form": "62a745944e836ccf1c0ba167"
  },
  "submission": {
    "_id": "62e052a316f1ad4f786d4038",
    "data": { "textField": "Test value", "number": 12345, "submit": true },
    "state": "submitted",
    "form": "62a745944e836ccf1c0ba167",
    "project": "61d21695ecb3f85c774e8f09",
    "created": "2022-07-26T20:46:27.232Z",
    "modified": "2022-07-26T20:46:27.233Z"
  },
  "params": { "formId": "62a745944e836ccf1c0ba167" }
}

```

The open-source `WebhookAction.js` also sends a `response` key, and for a `DELETE` it appends the URL parameters to the webhook URL as a query string. Form field values live under `submission.data`, keyed by each field's key, and `submission._id` identifies the submission across create, update and delete.

**Transform Payload** lets you write JavaScript that replaces the body entirely, so a customised action can send any shape. If you use it, make sure the output still includes the submission ID and, if you use a fixed method, the operation.

> **Inspect Form.io webhooks as they arrive.** Point a Webhook action at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact method, headers and body Form.io sends, with no account or setup required.

## Setting up Form.io webhooks

Pick a username and generate a long random password, for example with `openssl rand -hex 32`, and store both on your receiver.

1. Open the form (or resource) and go to **Actions**.
2. Add a **Webhook** action.
3. Set **Request URL** to your endpoint.
4. Leave **Request Method** on **Match** to receive `POST` for creates, `PUT` for updates and `DELETE` for deletes. A fixed method sends every operation with that one verb.
5. Enter the username in **Authorize User** and the password in **Authorize Password**.
6. Leave **Forward Headers** off unless you need the submitter's headers.
7. Optionally configure **Retry Request**, then save the action.
8. Submit the form from FormviewPro or your app, then edit and delete the submission, to see all three requests.

To put Hookdeck in front of your service, create a Hookdeck source with the **Form.io** type, use its URL as the **Request URL**, and enter the same **Username** and **Password** on the source. The source pre-selects `POST` only. With the Match method, also allow `PUT` and `DELETE` on the source, and `PATCH` or `GET` if your action uses a fixed method, or Hookdeck will not accept updates and deletes.

For local development, `hookdeck listen 3000 formio --path /webhooks/formio` gives you a public URL that forwards to your machine.

## Securing Form.io webhooks

Form.io builds the `Authorization: Basic base64(username:password)` header itself and sets it on the request, so the credentials arrive with the first attempt. Decode the header, split on the first colon, and compare the username and password with your stored values in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header, username, password) {
  if (!username || !password) return false; // never accept when unconfigured
  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64');
  const colon = decoded.indexOf(0x3a); // split on the first colon: passwords may contain ':'
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(username, 'utf8'));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(password, 'utf8'));
  return userOk && passOk; // evaluate both before deciding
}

const OPERATIONS = { POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' };

const app = express();

// With the Match method, creates, updates and deletes use different verbs.
app.all('/webhooks/formio', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyBasicAuth(
    req.get('authorization'),
    process.env.FORMIO_WEBHOOK_USERNAME,
    process.env.FORMIO_WEBHOOK_PASSWORD
  );
  if (!ok) return res.sendStatus(401);

  const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
  let payload = {};
  try {
    payload = raw ? JSON.parse(raw) : {};
  } catch {
    return res.sendStatus(400);
  }

  const operation = OPERATIONS[req.method] || 'unknown';
  const submissionId = payload.submission?._id;
  // Handle operation for submissionId; field values are in payload.submission?.data.
  // For a DELETE, the URL params also arrive in the query string (req.query).

  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os
import re

BASIC_PATTERN = re.compile(r"^basic\s+([A-Za-z0-9+/]+={0,2})\s*$", re.IGNORECASE)

def verify_basic_auth(header, username, password) -> bool:
    if not username or not password:
        return False  # never accept when unconfigured
    match = BASIC_PATTERN.match(header or "")
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True)
    except (binascii.Error, ValueError):
        return False
    received_user, sep, received_password = decoded.partition(b":")  # first colon only
    if not sep:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    user_ok = hmac.compare_digest(received_user, username.encode("utf-8"))
    password_ok = hmac.compare_digest(received_password, password.encode("utf-8"))
    return user_ok and password_ok

# verify_basic_auth(request.headers.get("authorization"),
#                   os.environ.get("FORMIO_WEBHOOK_USERNAME"),
#                   os.environ.get("FORMIO_WEBHOOK_PASSWORD"))

```

Basic credentials prove the sender knows the password. They do not cover the body, and there is no timestamp, so a modified or replayed request still passes. Use HTTPS, a long random password, and rotate it if it might have leaked. Keep **Forward Headers** off: it passes through the headers the Form.io server received, which can include the submitter's own `Authorization` header.

> **Make Form.io webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credentials, accepts creates, updates and deletes on one source, and retries delivery to your service without relying on the action's opt-in retries.

## Form.io webhook limitations and pain points

### Updates and deletes fail on a POST-only route

**The Problem:** New submissions arrive, but edits and deletions never do, or they fail with `404` or `405`.

**Why It Happens:** With the default Match method, an update is a `PUT` and a delete is a `DELETE`. A route, framework or gateway that only accepts `POST` refuses them.

**Workarounds:**

* Accept `POST`, `PUT` and `DELETE` on the route (and `PATCH` or `GET` if you use them), and treat an empty or missing body as `{}`.
* Map the method to an operation in one place, as in the example above.

**How Hookdeck Can Help:** Allow `PUT` and `DELETE` on the Hookdeck Form.io source, and every operation reaches your destination with its original method, so your handler can still tell them apart.

### A static credential instead of a signature

**The Problem:** Anyone who has the credentials can send requests your endpoint will accept, and nothing detects a modified body.

**Why It Happens:** The action's only security settings are Basic credentials and additional headers. Neither is computed over the body.

**Workarounds:**

* Use a long random password, HTTPS only, and a separate credential per form or environment.
* Treat incoming data as untrusted and validate it before writing it anywhere.

**How Hookdeck Can Help:** Hookdeck's Form.io source checks the Basic username and password on every request. Hookdeck still answers with a `200`, but a request with missing or wrong credentials is recorded as failed verification and never reaches your destination.

### Retries are off unless you turn them on

**The Problem:** If your endpoint is down when a form is submitted, the submission is saved in Form.io but your system never hears about it.

**Why It Happens:** By default the action fires once and does not wait for the response. **Retry Request** has to be configured per action, with a delay type, a maximum number of attempts and an initial delay in milliseconds.

**Workarounds:**

* Configure **Retry Request** with exponential delays on every action.
* Reconcile periodically by listing recent submissions through the Form.io API.

**How Hookdeck Can Help:** Hookdeck stores each request as it arrives, then delivers to your service with [retries](/docs/retries) you configure. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any request after a fix.

### Waiting for the response couples your form to your endpoint

**The Problem:** With **Wait for Webhook Response** on, a slow or failing endpoint slows down or blocks the user's submission, and errors appear on the form.

**Why It Happens:** That setting makes Form.io wait for your response before running the next actions, and stops the submission on an error. **External Id Type** and **Path** rely on the same response to store an ID from your system.

**Workarounds:**

* Leave the setting off unless the form genuinely needs your system's answer.
* If you need an external ID, keep that endpoint fast and highly available.

**How Hookdeck Can Help:** Through Hookdeck, Form.io receives Hookdeck's own `200` straight away, so the form never waits on your service. That also means your service's response body never reaches Form.io, so don't route actions that use **Wait for Webhook Response** or **External Id Path** through Hookdeck.

## Best practices

### Keep Match, or put the operation in the body

Match gives you the operation for free. If you need a fixed method, use **Transform Payload** to add an `operation` field so the receiver doesn't have to guess.

### Key everything on the submission ID

`submission._id` is stable across create, update and delete. Use it for upserts and deletes, and combine it with `submission.modified` to ignore an older update that arrives after a newer one. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle empty bodies

Deletes and `GET` requests can arrive with little or no body. Parse after authenticating, and treat an empty body as `{}`.

### Acknowledge fast

Return `200` once the request is authenticated and process it from a queue, especially if retries are on, since a slow response can turn into a duplicate. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

The Form.io Webhook action sends submissions with no event field, using the HTTP method to say whether an entry was created, updated or deleted, and authenticates with Basic credentials it sets on every request. Accept `POST`, `PUT` and `DELETE`, compare the credentials in constant time over HTTPS, key on `submission._id`, and turn on retries or put something durable in front of your endpoint.

[Hookdeck Event Gateway](https://hookdeck.com) checks the credentials, accepts all three operations on one source once you allow their methods, and holds each request until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Form.io webhooks reliably in minutes.