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:
{
"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 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.
- Open the form (or resource) and go to Actions.
- Add a Webhook action.
- Set Request URL to your endpoint.
- Leave Request Method on Match to receive
POSTfor creates,PUTfor updates andDELETEfor deletes. A fixed method sends every operation with that one verb. - Enter the username in Authorize User and the password in Authorize Password.
- Leave Forward Headers off unless you need the submitter's headers.
- Optionally configure Retry Request, then save the action.
- 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.
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:
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 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,PUTandDELETEon the route (andPATCHorGETif 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 you configure. 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.
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.
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 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 for free and handle Form.io webhooks reliably in minutes.