Forms Plugin
Forms Plugin
Collect form submissions, validate input, and queue webhook deliveries from any frontend.
The forms plugin ships with ModulaCMS as a Lua plugin at plugins/forms/. It provides 21 REST API endpoints for building forms, managing fields, accepting submissions, and configuring webhook notifications. A companion web components package (@modulacms/forms) renders forms, displays entries, and provides a drag-and-drop form builder for admin panels.
Good to know: The forms plugin requires
plugin_enabled: trueinmodula.config.json. See configuration for plugin setup.
Quick Start
1. Enable and Approve
# Start the server with plugins enabled
modulacms serve
# Verify the plugin loaded
modulacms plugin info forms
# Approve all routes
modulacms plugin approve forms --all-routes
2. Create a Form
curl -X POST http://localhost:8080/api/v1/plugins/forms/forms \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_KEY" \
-d '{
"name": "Contact",
"description": "Contact form for the marketing site",
"submit_label": "Send Message",
"success_message": "Thanks for reaching out. We will get back to you within 24 hours."
}'
Response:
{
"id": "01HXYZ...",
"name": "Contact",
"description": "Contact form for the marketing site",
"submit_label": "Send Message",
"success_message": "Thanks for reaching out. We will get back to you within 24 hours.",
"redirect_url": null,
"enabled": true,
"version": 1,
"rate_limit": 100,
"created_at": "2026-03-19T12:00:00Z"
}
3. Add Fields
# Email field (required)
curl -X POST http://localhost:8080/api/v1/plugins/forms/forms/01HXYZ.../fields \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_KEY" \
-d '{
"name": "email",
"label": "Email Address",
"field_type": "email",
"required": true,
"placeholder": "you@example.com",
"version": 1
}'
# Message field (required, with length limits)
curl -X POST http://localhost:8080/api/v1/plugins/forms/forms/01HXYZ.../fields \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_KEY" \
-d '{
"name": "message",
"label": "Message",
"field_type": "textarea",
"required": true,
"help_text": "Tell us how we can help.",
"validation_rules": {"min_length": 10, "max_length": 2000},
"version": 2
}'
Good to know: Every field mutation requires a
versionfield matching the current form version. The response includes the new version number. This prevents two people from editing the same form simultaneously without knowing about each other's changes.
4. Accept Submissions
The public submit endpoint requires no authentication:
curl -X POST http://localhost:8080/api/v1/plugins/forms/forms/01HXYZ.../submit \
-H "Content-Type: application/json" \
-d '{
"email": "visitor@example.com",
"message": "I am interested in your services."
}'
Response:
{
"id": "01HABCD...",
"message": "Thanks for reaching out. We will get back to you within 24 hours.",
"redirect_url": null
}
Concepts
Form Versioning
Every form has a version number that increments on every change to the form or its fields. When someone submits an entry, the submission records which version it was submitted against (form_version). This lets you correlate entries with the field configuration that was active when they were submitted.
Endpoints that modify forms or fields require the current version in the request body. If the version does not match, the server returns 409 Conflict with the current version number so the client can refresh and retry.
Field Types
| Type | HTML Element | Validation |
|---|---|---|
text |
<input type="text"> |
Optional min/max length |
textarea |
<textarea> |
Optional min/max length |
email |
<input type="email"> |
Must contain @ with valid local and domain parts |
number |
<input type="number"> |
Must be a valid number (integer or decimal) |
tel |
<input type="tel"> |
No format enforcement |
url |
<input type="url"> |
Must start with http:// or https:// |
date |
<input type="date"> |
No format enforcement |
time |
<input type="time"> |
No format enforcement |
datetime |
<input type="datetime-local"> |
No format enforcement |
select |
<select> |
Value must be in the options array |
radio |
Radio button group | Value must be in the options array |
checkbox |
<input type="checkbox"> |
When required, must be checked |
hidden |
<input type="hidden"> |
No validation |
file |
<input type="file"> |
Base64 encoded, ~750KB max per submission |
Fields with select or radio types require an options array in the field definition:
{
"name": "department",
"label": "Department",
"field_type": "select",
"options": ["Sales", "Support", "Engineering", "Other"],
"required": true,
"version": 3
}
Validation Rules
Each field can include a validation_rules object:
| Rule | Type | Description |
|---|---|---|
min_length |
number | Minimum string length |
max_length |
number | Maximum string length |
max_file_size |
number | Maximum file size in bytes (file fields only, default 768000) |
Validation runs server-side on every submission. The web components also validate client-side before sending, using the same rules.
Anti-Spam Protection
Three layers protect public submit endpoints:
-
Honeypot field: A hidden
_hpfield that bots tend to fill. If populated, the server returns a fake success response (identical to a real one) without creating an entry. -
Per-IP rate limiting: The plugin HTTP bridge applies automatic per-IP throttling to all plugin routes.
-
Per-form submission throttle: Each form has a
rate_limit(default 100 submissions per hour). When the limit is reached, the server returns429 Too Many Requests. The counter resets automatically after one hour.
Webhook Queue
When a submission is created or deleted, the plugin inserts rows into a webhook delivery queue. A separate process (not part of the plugin) reads the queue and delivers HTTP requests. The plugin is responsible for queue population only.
Each queue row contains the full delivery configuration (URL, method, headers, secret) so it remains deliverable even if the webhook configuration is later deleted.
Webhook events:
| Event | Trigger |
|---|---|
entry.created |
New form submission |
entry.deleted |
Entry deleted via admin API |
form.deleted |
Form deleted (one event per active webhook, not per entry) |
API Reference
All endpoints are under /api/v1/plugins/forms/.
Public Endpoints
These require no authentication.
GET /forms/{id}/public
Retrieve an enabled form with its fields for rendering. Returns the form definition, field list, and CAPTCHA configuration (if any). Does not include admin-only fields like captcha_secret or rate limit counters.
POST /forms/{id}/submit
Submit a form entry. The request body is a JSON object mapping field names to values:
{
"email": "visitor@example.com",
"message": "Hello",
"_hp": ""
}
Returns 201 with {id, message, redirect_url} on success, 400 for validation errors, 429 when rate limited.
Admin Endpoints
All admin endpoints require authentication via X-API-Key header or session cookie.
Forms
| Method | Path | Description |
|---|---|---|
| GET | /forms |
List forms (paginated: ?limit=, ?offset=) |
| POST | /forms |
Create a form |
| GET | /forms/{id} |
Get form with fields |
| PUT | /forms/{id} |
Update form (requires version in body) |
| DELETE | /forms/{id} |
Delete form and all related data |
Updatable form fields: name, description, submit_label, success_message, redirect_url, enabled, captcha_config, captcha_secret, rate_limit.
Fields
| Method | Path | Description |
|---|---|---|
| GET | /forms/{id}/fields |
List fields ordered by position |
| POST | /forms/{id}/fields |
Add a field (requires version) |
| PUT | /fields/{id} |
Update a field (requires version) |
| DELETE | /fields/{id} |
Delete a field (requires version in body) |
| POST | /forms/{id}/fields/reorder |
Reorder all fields |
Reorder requires field_ids (array of all field IDs in desired order) and version:
{
"field_ids": ["01FIELD_C", "01FIELD_A", "01FIELD_B"],
"version": 5
}
Entries
| Method | Path | Description |
|---|---|---|
| GET | /forms/{id}/entries |
List entries (paginated) |
| GET | /entries/{id} |
Get a single entry |
| DELETE | /entries/{id} |
Delete an entry |
| GET | /forms/{id}/export |
Export entries as JSON |
Export uses cursor-based pagination. Each request returns up to 10,000 entries. Pass ?after=<last_entry_id> for the next page:
# First page
curl "http://localhost:8080/api/v1/plugins/forms/forms/01HXYZ.../export"
# Next page
curl "http://localhost:8080/api/v1/plugins/forms/forms/01HXYZ.../export?after=01LAST_ID"
Response:
{
"items": [...],
"count": 10000,
"after": "01LAST_ENTRY_ID",
"has_more": true
}
Webhooks
| Method | Path | Description |
|---|---|---|
| GET | /forms/{id}/webhooks |
List webhooks for a form |
| POST | /forms/{id}/webhooks |
Create a webhook |
| PUT | /webhooks/{id} |
Update a webhook |
| DELETE | /webhooks/{id} |
Delete a webhook |
| GET | /forms/{id}/webhooks/queue |
Queue depth and recent failures |
Create webhook example:
{
"url": "https://hooks.slack.com/services/T00/B00/xxxx",
"method": "POST",
"events": "entry.created",
"headers": {"Authorization": "Bearer token"},
"secret": "whsec_signing_key"
}
Queue info returns pending count, failed count, and the 10 most recent failures:
{
"pending": 3,
"failed": 1,
"recent_failures": [
{
"id": "01QUEUE...",
"webhook_id": "01WH...",
"event": "entry.created",
"last_error": "connection refused",
"attempts": 3,
"created_at": "2026-03-19T12:00:00Z"
}
]
}
Response Formats
Success (create): 201 with the created object including id.
Success (update): 200 with the full updated object.
Success (delete): 200 with {"deleted": true}.
Success (list): 200 with {"items": [...], "total": N, "limit": N, "offset": N}.
Error: {"error": "description of what went wrong"} with appropriate status code.
Version conflict: 409 with {"error": "version conflict", "current_version": N}.
Configuration
Forms plugin behavior is controlled by global plugin settings in modula.config.json:
| Setting | Default | Description |
|---|---|---|
plugin_max_ops |
1000 | Maximum database operations per request |
plugin_max_request_body |
1MB | Maximum request body size (limits file upload size) |
plugin_rate_limit |
varies | Per-IP rate limiting for all plugin routes |
Per-form settings:
| Setting | Default | Description |
|---|---|---|
rate_limit |
100 | Maximum submissions per hour per form |
enabled |
true | Whether the form accepts submissions |
CAPTCHA
To add CAPTCHA to a form, update captcha_config with your provider details:
{
"captcha_config": {
"provider": "recaptcha",
"site_key": "6Lc..."
},
"captcha_secret": "6Lc..._secret",
"version": 4
}
The web components read captcha_config and render the provider's challenge widget automatically. Server-side CAPTCHA validation is planned for a future release.
Good to know: Store the
captcha_secretseparately fromcaptcha_config. The public form endpoint returnscaptcha_config(withsite_key) but never exposescaptcha_secret.
Known Limitations
- File uploads are stored as base64 in the database. The maximum total submission size is ~750KB due to the 1MB request body limit. For larger files, use the CMS media system instead.
- Webhook delivery is handled by a separate queue processor, not by the plugin itself. The plugin inserts queue rows only.
- Server-side CAPTCHA validation is not available yet. The CAPTCHA widget renders client-side for UX consistency.
- Form deletion queues a single
form.deletedevent per webhook, not per-entry events. Export entries before deleting if downstream systems need entry-level notifications. - Secrets are stored as plaintext. Webhook signing secrets and CAPTCHA secret keys are stored unencrypted. Restrict database access and treat backups as sensitive.
Next Steps
- Embed forms on your site using the
@modulacms/formsweb components - Lua API reference for extending plugin behavior
- Plugin approval for managing route permissions
- Webhook integration for processing the delivery queue