# API Keys
Source: https://docs.opnform.com/api-reference/api-keys
Generate and manage Personal Access Tokens on OpnForm.
Personal Access Tokens are the credentials you'll use to authenticate with the OpnForm REST API. They're scoped, revocable, and tied to your user account – **keep them secret**.
## Creating a token
1. Sign in to your OpnForm account.
2. Open **Settings → Access Tokens** (`/home?user-settings=access-tokens`).
3. Click **Create new token**.
4. Pick a descriptive name (e.g. "Zapier Integration").
5. Select the abilities you want to grant (see the table below).
6. Click **Create** and copy the token value – you **won't** be able to see it again after closing the dialog.
## Abilities
| Ability | Grants |
| - | - |
| workspaces-read | List workspaces |
| workspaces-write | Create, update or delete workspaces |
| workspace-users-read | List members and invites |
| workspace-users-write | Manage members and invites |
| forms-read | List forms and submissions |
| forms-write | Create or modify forms and submissions |
You can combine abilities as needed – choose the minimum set your integration requires.
## Using the token
Send the token in the `Authorization` header:
```http theme={null}
Authorization: Bearer
```
## Revoking a token
If a token is leaked or no longer needed:
1. Go back to **Settings → Access Tokens**.
2. Click **Revoke** next to the token.
3. Any further API requests that use that token will receive `401 Unauthorized`.
## Best practices
* Store tokens securely (environment variables, secret managers, CI secrets).
* Use a dedicated token per integration – easier to revoke individually.
* Avoid committing tokens to version control.
* Rotate tokens periodically.
# Changelog
Source: https://docs.opnform.com/api-reference/changelog
Notable updates to the OpnForm API.
## 2026-10-07 – Export status polling with API tokens
Personal Access Tokens with `forms-read` can now poll asynchronous exports at
`GET /open/forms/{id}/submissions/export/status/{job_id}`. The previously documented
`/export/{job_id}/status` path was incorrect.
The job must belong to the requested form, and you must have access to that form.
A job from another form returns `404`.
The response documentation now includes the initial `queued` status and matches
the ten fields returned by the API, including `null` values for fields that do not apply.
## 2026-10-01 – Removed unused public form users endpoint
`GET /forms/{form}/users` has been removed and now returns `404`. The application does not use this endpoint.
To list workspace members, use the authenticated `GET /open/workspaces/{workspaceId}/users` endpoint with the `workspace-users-read` token ability.
## 2026-05-27 – Workspace invite listing requires admin access
The workspace invite listing endpoint now requires workspace admin access:
* `GET /open/workspaces/{workspaceId}/invites`
### What changed
* Non-admin workspace members can no longer list pending or accepted workspace invites.
* Personal Access Tokens must include `workspaces-write` and belong to a workspace admin.
* Invite responses do not expose invite tokens.
### Migration guide
If your integration lists workspace invites, use an admin-owned token with the `workspaces-write` ability. Continue using `GET /open/workspaces/{workspaceId}/users` with `workspace-users-read` when you only need accepted workspace members.
## 2025-01-02 – Optimized form list endpoint
The form list endpoint now returns **lightweight form summaries** for improved performance:
* `GET /open/workspaces/{workspaceId}/forms`
The deprecated `GET /open/forms` endpoint (which lists all forms across workspaces) has been removed from the documentation. It remains available for existing Zapier integrations but is not recommended for new implementations.
### What changed
This endpoint no longer includes the `properties` array (form fields and blocks) in the response. This significantly reduces response size and improves load times, especially for workspaces with many forms or complex form structures.
### Response fields
The list endpoint now returns these fields:
| Field | Description |
| - | - |
| `id`, `slug`, `title`, `visibility`, `tags` | Core form identification |
| `views_count`, `submissions_count` | Form statistics |
| `created_at`, `updated_at`, `last_edited_human` | Timestamps (ISO 8601 + human-readable) |
| `closes_at`, `is_closed` | Form closure status |
| `max_submissions_count`, `max_number_of_submissions_reached` | Submission limits |
| `is_pro`, `workspace_id`, `share_url` | Workspace and sharing info |
### Migration guide
If your integration relies on `properties` from list endpoints, update your code to:
1. Fetch the form list to get form IDs/slugs
2. Call `GET /open/forms/{slug}` for each form where you need the full `properties` array
```javascript theme={null}
// Before: properties were included in list response
const forms = await fetch('/open/workspaces/1/forms');
// After: fetch full form details when needed
const formList = await fetch('/open/workspaces/1/forms');
const fullForm = await fetch(`/open/forms/${formList.data[0].slug}`);
// fullForm.properties is now available
```
***
## 2024-06-18 – API creation 🎉
The first public version of the OpnForm REST API is live! You can now:
* Manage workspaces and members
* Create, update and delete forms
* Retrieve and manage submissions
* Generate personal access tokens with granular abilities
Stay tuned for more endpoints and features.
# Create Form
Source: https://docs.opnform.com/api-reference/forms/create-form
post /open/forms
Create a new form; requires forms-write ability.
# Create Form
Create a new form within a workspace.
## Authentication & Scope
Requires a Personal Access Token with the `forms-write` ability.
## Request
```http theme={null}
POST /open/forms HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| workspace\_id | number | Yes | ID of the workspace that will own the form. |
| title | string | Yes | Human-readable title shown in the dashboard. |
| visibility | string | Yes | One of `public`, `draft`, or `closed`. |
| language | string | Yes | Two-letter ISO language code (e.g. `en`). |
| properties | array | Yes | Array of form blocks (see `FormProperty` schema). |
| …other fields | mixed | No | See the `Form` schema for the full list of attributes. |
Example request:
```json theme={null}
{
"workspace_id": 1,
"title": "Event Registration",
"visibility": "public",
"language": "en",
"properties": [
{
"id": "field-1",
"type": "short_text",
"name": "First name",
"required": true
}
]
}
```
## Response
`201 Created` – Returns the full `Form` object.
```json theme={null}
{
"id": 99,
"slug": "event-registration",
"title": "Event Registration",
"visibility": "public",
"properties": [...]
}
```
`403 Forbidden` – The token does not include the `forms-write` ability.
# Delete Form
Source: https://docs.opnform.com/api-reference/forms/delete-form
delete /open/forms/{id}
Delete a form. Requires `forms-write`.
# Delete Form
Permanently delete a form and all of its submissions.
## Authentication & Scope
Requires a Personal Access Token that includes the `forms-write` ability.
## Request
```http theme={null}
DELETE /open/forms/{id} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the form to delete |
## Response
`204 No Content` – Form successfully deleted.
`403 Forbidden` – The token lacks `forms-write` or you are not allowed to delete this form.
# Get Form
Source: https://docs.opnform.com/api-reference/forms/get-form
get /open/forms/{slug}
Retrieve a single form.
# Get Form
Retrieve a single form by its **slug** (UUID or human-readable slug).
## Authentication & Scope
Requires a token with the `forms-read` ability.
## Request
```http theme={null}
GET /open/forms/{slug} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| slug | string | The form slug or UUID (see dashboard) |
## Response
`200 OK` – Returns a `Form` object.
```json theme={null}
{
"id": 42,
"slug": "customer-feedback",
"title": "Customer Feedback",
"visibility": "public",
"properties": [...]
}
```
`404 Not Found` – No form matches the provided slug.
`403 Forbidden` – The token lacks the required ability or you do not have permission to view the form.
# List Workspace Forms
Source: https://docs.opnform.com/api-reference/forms/list-workspace-forms
get /open/workspaces/{workspaceId}/forms
Retrieve all forms within a specific workspace. Returns lightweight form summaries (excludes `properties`). Use the Get Form endpoint to retrieve the full form with all fields. Requires `forms-read` ability.
Retrieve all forms within a specific workspace.
This endpoint returns **lightweight form summaries** optimized for listing. The `properties` array (form fields and blocks) is excluded to reduce response size. Use the [Get Form](./get-form) endpoint to retrieve the complete form with all fields.
## Authentication & Scope
This request must be authenticated using a **Personal Access Token** that includes the `forms-read` ability.
```http theme={null}
Authorization: Bearer
```
## Request
```http theme={null}
GET /open/workspaces/{workspaceId}/forms HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace to retrieve forms from. |
### Query Parameters
| Parameter | Type | Description | Default |
| - | - | - | - |
| page | number | Page number for pagination. | 1 |
| per\_page | number | Number of forms per page (minimum 1, maximum 100). | 10 |
## Response
`200 OK` – Returns a paginated JSON response containing lightweight form summaries.
```json theme={null}
{
"data": [
{
"id": 42,
"slug": "customer-feedback",
"title": "Customer Feedback",
"visibility": "public",
"tags": ["feedback", "support"],
"views_count": 1250,
"submissions_count": 15,
"created_at": "2024-11-15T10:30:00+00:00",
"updated_at": "2025-01-02T14:25:00+00:00",
"last_edited_human": "2 hours ago",
"closes_at": null,
"is_closed": false,
"max_submissions_count": null,
"max_number_of_submissions_reached": false,
"is_pro": true,
"workspace_id": 1,
"share_url": "https://opnform.com/forms/customer-feedback"
}
],
"meta": {
"current_page": 1,
"per_page": 10,
"total": 1
}
}
```
To get the full form including fields (`properties`), call the [Get Form](./get-form) endpoint with the form's `slug` or `id`.
`403 Forbidden` – The token does not include the required `forms-read` ability, or you do not have access to the workspace.
`404 Not Found` – The workspace does not exist.
# Update Form
Source: https://docs.opnform.com/api-reference/forms/update-form
put /open/forms/{id}
Update an existing form. Requires `forms-write`.
# Update Form
Update an existing form. You can modify any attribute that can be set when creating a form.
## Authentication & Scope
Requires a token with the `forms-write` ability.
## Request
```http theme={null}
PUT /open/forms/{id} HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the form to edit |
### Body Parameters
All fields from the [Create Form](./create-form) endpoint may be supplied. However, **the API validation requires you to include specific fields** in every update request, even if you only want to change one optional field.
#### Required Fields in Updates
These fields must always be included:
| Field | Type | Description |
| - | - | - |
| title | string | Form title (max 60 characters) |
| visibility | string | Form visibility state (`"public"`, `"closed"`, `"draft"`) |
| language | string | Two-letter ISO language code (e.g. `en`) |
| theme | string | Form theme |
| presentation\_style | string | How the form is presented |
| width | string | Form container width |
| size | string | Form text size |
| border\_radius | string | Form border radius |
| dark\_mode | string | Dark mode setting |
| color | string | Primary color (hex format) |
| uppercase\_labels | boolean | Whether labels should be uppercase |
| no\_branding | boolean | Hide OpnForm branding |
| transparent\_background | boolean | Use transparent background |
| properties | array | Array of form fields/blocks — **must never be empty**, or existing properties will be lost |
| …other fields | mixed | All other fields from Create Form (description, logo\_picture, etc.) |
**Important:** Omitting any of the required fields will result in a validation error. You must include all these fields in your update request, even if you're only changing one optional field. Additionally, the `properties` array cannot be empty — always send your complete form fields.
### Recommended Update Pattern
To safely update a form without accidentally losing properties:
1. **Fetch** the current form state using the [Get Form](./get-form) endpoint
2. **Modify** only the fields you want to change in the fetched response
3. **Send** the complete updated form (including all required fields and properties) back to this endpoint
This ensures you retain all existing form fields and don't accidentally overwrite them with empty arrays.
**Best Practice Example:** If you only want to change form visibility, fetch the form first, update only the `visibility` field locally, then send the complete form back with all its properties intact.
### Automatically delete old submissions
Set `submission_retention_value` and `submission_retention_unit` together to permanently delete submissions after a retention period. The period is measured from each submission's last update, and the cleanup runs hourly.
```json theme={null}
{
"submission_retention_value": 3,
"submission_retention_unit": "day"
}
```
The unit accepts `day`, `week`, `month`, or `year`. Set both fields to `null` to disable automatic deletion.
Expired submissions, uploaded files, and submission versions are permanently deleted and cannot be restored. Shortening an existing retention period can make older submissions eligible during the next hourly cleanup.
### Body Example
Example request updating title and visibility (note: all required fields must be included):
```json theme={null}
{
"title": "Customer Feedback (v2)",
"visibility": "closed",
"language": "en",
"theme": "light",
"presentation_style": "default",
"width": "normal",
"size": "medium",
"border_radius": "medium",
"dark_mode": "off",
"color": "#3b82f6",
"uppercase_labels": false,
"no_branding": false,
"transparent_background": false,
"properties": [
{
"id": "field-1",
"type": "short_text",
"name": "First name",
"required": true
}
],
"closed_text": "Form is currently closed"
}
```
## Response
`200 OK` – Returns the updated `Form` object.
`403 Forbidden` – The token lacks `forms-write` or you don't have permission.
# Create Webhook Integration
Source: https://docs.opnform.com/api-reference/integrations/create-webhook-integration
post /open/forms/{form}/integrations
Create a new generic webhook or provider-specific Make integration for a form. Requires `manage-integrations` ability.
# Create Webhook Integration
Add a new webhook integration to send form submissions to an external endpoint.
## Authentication & Scope
This endpoint requires a **Personal Access Token** with the `manage-integrations` ability.
## Request
The ID of the form to which the webhook will be added.
Use `"webhook"` for a generic webhook integration or `"make"` for the
official OpnForm app on Make. Both values are registered integration
handlers exposed by this endpoint.
The initial status of the webhook. Allowed values: `"active"`, `"inactive"`.
Configuration object containing webhook details.
The URL where form submissions will be sent. Must be a valid HTTPS URL that resolves only to public IP addresses. Private, loopback, link-local, and cloud metadata addresses are rejected.
Optional signing secret for HMAC-SHA256 validation. When provided, webhook
requests will include an `X-Webhook-Signature` header. Must be at least 12
characters. Recommended for security. Should be a random, cryptographically
secure string.
Optional custom HTTP headers to send with each webhook request. Provided as key-value pairs where both keys and values are strings. Maximum 10 headers allowed, each value max 255 characters.
**Blocked headers** (cannot be customized): `Authorization`, `X-Webhook-Signature`, `Content-Type`, `Host`, `Cookie`, `X-CSRF-Token`, `Content-Length`, and others reserved for security.
Example:
```json theme={null}
{
"X-API-Key": "your-api-key",
"X-Custom-ID": "custom-value"
}
```
Optional Make scenario URL. This property is accepted by the `"make"`
integration and is ignored by generic webhook integrations.
Optional conditional logic to trigger the webhook only when specific
conditions are met.
```bash cURL theme={null}
curl -X POST 'https://api.opnform.com/open/forms/123/integrations' \
-H 'Authorization: Bearer YOUR_PAT' \
-H 'Content-Type: application/json' \
-d '{
"integration_id": "webhook",
"status": "active",
"data": {
"webhook_url": "https://example.com/opnform-hook",
"webhook_secret": "whsec_1234567890abcdefghijklmnop",
"webhook_headers": {
"X-API-Key": "my-api-key",
"X-Custom-Header": "custom-value"
}
}
}'
```
For the official OpnForm app on Make, the attach request uses the same endpoint
with the provider-specific integration identifier:
```bash cURL Make theme={null}
curl -X POST 'https://api.opnform.com/open/forms/123/integrations' \
-H 'Authorization: Bearer YOUR_PAT' \
-H 'Content-Type: application/json' \
-d '{
"integration_id": "make",
"status": "active",
"logic": null,
"data": {
"webhook_url": "https://hook.eu1.make.com/example"
}
}'
```
## Response
`200 OK` – Webhook created successfully.
```json Success theme={null}
{
"message": "Form Integration was created.",
"form_integration": {
"id": 42,
"form_id": 123,
"integration_id": "webhook",
"status": "active",
"data": {
"webhook_url": "https://example.com/opnform-hook",
"webhook_secret": "whsec_1234567890abcdefghijklmnop",
"webhook_headers": {
"X-API-Key": "my-api-key",
"X-Custom-Header": "custom-value"
}
}
}
}
```
`403 Forbidden` – The token does not have `manage-integrations` ability or insufficient form permissions.
`404 Not Found` – Form not found.
`422 Unprocessable Entity` – Validation error (e.g., invalid or non-public webhook URL, webhook\_secret too short, blocked header).
```json Error theme={null}
{
"message": "The given data was invalid.",
"errors": {
"data.webhook_url": ["The webhook URL must use HTTPS."],
"data.webhook_secret": ["The webhook secret must be at least 12 characters."],
"data.webhook_headers": ["The 'Authorization' header cannot be customized for security reasons."]
}
}
```
## Security
If you provide a `webhook_secret` when creating the webhook, OpnForm will sign each webhook request with an HMAC-SHA256 signature. This allows you to verify that the webhook came from OpnForm and hasn't been tampered with.
Webhook URLs are validated when they are saved and again before each delivery. OpnForm does not follow webhook redirects, and private network destinations are blocked unless the instance operator explicitly enables private webhook URLs for a self-hosted deployment.
Each webhook request will include:
* **`X-Webhook-Signature` header**: Contains the signature in format `sha256=HEXADECIMAL_VALUE`
* **Custom headers**: Any headers you specified in `webhook_headers` (except blocked headers)
* **JSON body metadata**: The payload includes `form_id` and `submission_id` so you can correlate webhook deliveries with OpnForm API submission management endpoints
### Blocked Headers
For security reasons, the following headers cannot be customized:
* `Authorization`
* `X-Webhook-Signature`
* `Content-Type`
* `Content-Length`
* `Host`
* `Cookie`
* `X-CSRF-Token`
* `X-Forwarded-For`
* `X-Forwarded-Proto`
* `X-Real-IP`
See [Validating Webhook Signatures](/api-reference/integrations/webhook-security) for implementation examples.
## Make payload
The `"make"` handler sends a provider-specific payload matching the output
interface of the OpnForm Make app:
```json theme={null}
{
"form_id": 123,
"form_title": "Contact Form",
"form_slug": "contact-form",
"submission_id": 456,
"edit_link": "https://opnform.com/forms/contact-form?submission_id=example",
"data": {
"field-id": {
"value": "Jane Doe",
"name": "Name",
"type": "text"
}
}
}
```
`edit_link` is present only when editable submissions are enabled. Unlike the
generic webhook payload, the Make payload omits the deprecated `submission` and
`message` properties.
Do not commit webhook secrets to version control. Use environment variables
or secure vaults to manage them.
# Delete Webhook Integration
Source: https://docs.opnform.com/api-reference/integrations/delete-webhook-integration
delete /open/forms/{form}/integrations/{integrationid}
Delete a webhook integration. Requires `manage-integrations` ability.
# Delete Webhook Integration
Remove a webhook integration from a form.
## Authentication & Scope
This endpoint requires a **Personal Access Token** with the `manage-integrations` ability.
## Request
The ID of the form containing the webhook.
The ID of the webhook integration to delete.
```http theme={null}
DELETE /open/forms/{form}/integrations/{integrationid} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
```bash cURL theme={null}
curl -X DELETE 'https://api.opnform.com/open/forms/123/integrations/42' \
-H 'Authorization: Bearer YOUR_PAT'
```
## Response
`200 OK` – Webhook deleted successfully.
```json Success theme={null}
{
"message": "Form Integration was deleted."
}
```
`403 Forbidden` – The token does not have `manage-integrations` ability or insufficient form permissions.
`404 Not Found` – Form or integration not found.
# List Form Integrations
Source: https://docs.opnform.com/api-reference/integrations/list-form-integrations
get /open/forms/{form}/integrations
List all integrations configured for a form. Requires `manage-integrations` ability.
# List Form Integrations
Retrieve all integrations configured for a specific form.
## Authentication & Scope
This endpoint requires a **Personal Access Token** with the `manage-integrations` ability.
## Request
The ID of the form for which to list integrations.
```http theme={null}
GET /open/forms/{form}/integrations HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
## Response
`200 OK` – Returns an array of integration objects.
```json Success theme={null}
[
{
"id": 42,
"form_id": 123,
"integration_id": "webhook",
"status": "active",
"data": {
"webhook_url": "https://example.com/opnform-hook"
}
},
{
"id": 43,
"form_id": 123,
"integration_id": "webhook",
"status": "inactive",
"data": {
"webhook_url": "https://old-endpoint.com/hook"
}
}
]
```
`403 Forbidden` – The token does not include the `manage-integrations` ability, or you don't have permission to update this form.
# List Webhook Events
Source: https://docs.opnform.com/api-reference/integrations/list-webhook-events
get /open/forms/{form}/integrations/{integrationid}/events
List past webhook dispatch events for an integration. Requires `manage-integrations` ability.
# List Webhook Events
Retrieve a paginated list of webhook dispatch events for a specific integration.
## Authentication & Scope
This endpoint requires a **Personal Access Token** with the `manage-integrations` ability.
## Request
The ID of the form containing the webhook.
The ID of the webhook integration for which to list events.
```http theme={null}
GET /open/forms/{form}/integrations/{integrationid}/events HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
```bash cURL theme={null}
curl -X GET 'https://api.opnform.com/open/forms/123/integrations/42/events' \
-H 'Authorization: Bearer YOUR_PAT'
```
## Response
`200 OK` – Returns an array of event objects, ordered by creation date (newest first).
```json Success theme={null}
[
{
"id": 1001,
"integration_id": 42,
"event": "submission.created",
"status": "success",
"response_code": 200,
"created_at": "2024-01-15T10:30:00Z"
},
{
"id": 1000,
"integration_id": 42,
"event": "submission.created",
"status": "failed",
"response_code": 500,
"error_message": "Internal Server Error",
"created_at": "2024-01-15T10:25:00Z"
},
{
"id": 999,
"integration_id": 42,
"event": "submission.created",
"status": "success",
"response_code": 200,
"created_at": "2024-01-15T10:20:00Z"
}
]
```
## Event object properties
Unique identifier for the event record.
The ID of the webhook integration that generated this event.
The type of event. Currently: `"submission.created"`.
Result of the webhook dispatch. Values: `"success"`, `"failed"`, `"timeout"`.
HTTP status code returned by your webhook endpoint (if applicable).
Error details if the webhook dispatch failed.
ISO 8601 timestamp of when the webhook was dispatched.
## Error responses
`403 Forbidden` – The token does not have `manage-integrations` ability or insufficient form permissions.
`404 Not Found` – Form or integration not found.
## Use cases
* **Debugging**: Check recent events to verify your webhook is receiving requests.
* **Monitoring**: Track success/failure rates to identify reliability issues.
* **Audit trail**: Review when submissions were sent to your endpoint.
# Update Webhook Integration
Source: https://docs.opnform.com/api-reference/integrations/update-webhook-integration
put /open/forms/{form}/integrations/{integrationid}
Update an existing webhook integration. Requires `manage-integrations` ability.
# Update Webhook Integration
Modify an existing webhook integration's URL, secret, headers, or status.
## Authentication & Scope
This endpoint requires a **Personal Access Token** with the `manage-integrations` ability.
## Request
The ID of the form containing the webhook.
The ID of the webhook integration to update.
Must be set to `"webhook"`.
The status of the webhook. Allowed values: `"active"`, `"inactive"`.
Configuration object containing webhook details. All fields are optional.
The updated URL where form submissions will be sent. Must be a valid HTTPS URL that resolves only to public IP addresses. Private, loopback, link-local, and cloud metadata addresses are rejected.
Update the signing secret for HMAC-SHA256 validation. Must be at least 12
characters. Provide a new value to rotate the secret.
Update custom HTTP headers. Replaces all existing headers with the provided ones. Maximum 10 headers allowed. See blocked headers list below for restricted header names.
Update the conditional logic for webhook triggering.
```bash cURL theme={null}
curl -X PUT 'https://api.opnform.com/open/forms/123/integrations/42' \
-H 'Authorization: Bearer YOUR_PAT' \
-H 'Content-Type: application/json' \
-d '{
"integration_id": "webhook",
"status": "active",
"data": {
"webhook_url": "https://new-endpoint.com/opnform-hook",
"webhook_secret": "whsec_newsecret123456789abcdef",
"webhook_headers": {
"X-API-Key": "new-api-key",
"X-Custom-ID": "updated-value"
}
}
}'
```
## Response
`200 OK` – Webhook updated successfully.
```json Success theme={null}
{
"message": "Form Integration was updated.",
"form_integration": {
"id": 42,
"form_id": 123,
"integration_id": "webhook",
"status": "active",
"data": {
"webhook_url": "https://new-endpoint.com/opnform-hook",
"webhook_secret": "whsec_newsecret123456789abcdef",
"webhook_headers": {
"X-API-Key": "new-api-key",
"X-Custom-ID": "updated-value"
}
}
}
}
```
`403 Forbidden` – The token does not have `manage-integrations` ability or insufficient form permissions.
`404 Not Found` – Form or integration not found.
`422 Unprocessable Entity` – Validation error.
```json Error theme={null}
{
"message": "The given data was invalid.",
"errors": {
"data.webhook_url": ["The webhook URL must use HTTPS."],
"data.webhook_secret": ["The webhook secret must be at least 12 characters."],
"data.webhook_headers": ["The 'Authorization' header cannot be customized for security reasons."]
}
}
```
## Security
Webhook URLs are validated when they are saved and again before each delivery. OpnForm does not follow webhook redirects, and private network destinations are blocked unless the instance operator explicitly enables private webhook URLs for a self-hosted deployment.
### Secret Rotation
When updating the `webhook_secret`, the new secret will be used for all future webhook requests. Existing webhook attempts with the old secret will fail validation on the receiver's end.
If you need to rotate your secret, consider implementing a grace period on your receiving endpoint to accept both old and new secrets during the transition.
### Blocked Headers
For security reasons, the following headers cannot be customized in `webhook_headers`:
* `Authorization`
* `X-Webhook-Signature`
* `Content-Type`
* `Content-Length`
* `Host`
* `Cookie`
* `X-CSRF-Token`
* `X-Forwarded-For`
* `X-Forwarded-Proto`
* `X-Real-IP`
For signature validation implementation examples, see [Validating Webhook Signatures](/api-reference/integrations/webhook-security).
# Validating Webhook Signatures
Source: https://docs.opnform.com/api-reference/integrations/webhook-security
Learn how to validate webhook signatures from OpnForm to ensure authenticity and integrity
# Validating Webhook Signatures
When you configure a webhook with a signing secret, OpnForm signs each webhook request with an HMAC-SHA256 signature. This allows you to verify that:
1. The webhook came from OpnForm (authenticity)
2. The payload hasn't been modified in transit (integrity)
## Understanding the Signature
Each webhook request includes an `X-Webhook-Signature` header with the format:
```
X-Webhook-Signature: sha256=HEXADECIMAL_VALUE
```
The signature is calculated as:
```
signature = HMAC-SHA256(webhook_secret, request_body)
```
Where:
* `webhook_secret` is the secret you provided when creating the webhook
* `request_body` is the raw JSON payload
## Validation Steps
1. Extract the signature from the `X-Webhook-Signature` header
2. Remove the `sha256=` prefix
3. Calculate the expected signature using your webhook secret and the raw request body
4. Compare the received signature with the calculated signature
5. Reject the webhook if signatures don't match
Always use the **raw request body** (as bytes/string before parsing) when calculating the signature. Parsing JSON and re-serializing can produce different output and cause signature mismatches.
## Implementation Examples
```javascript Node.js / Express theme={null}
const express = require('express');
const crypto = require('crypto');
const app = express();
// Important: Use raw body middleware to access raw request data
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const secret = process.env.OPNFORM_WEBHOOK_SECRET;
if (!signature || !secret) {
return res.status(401).send('Unauthorized: Missing signature or secret');
}
// Calculate expected signature using raw body
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(signature, expectedSignature)) {
return res.status(401).send('Unauthorized: Invalid signature');
}
// Signature is valid, parse and process the webhook
const data = JSON.parse(req.body);
console.log('Webhook received:', data);
res.status(200).send('Webhook received');
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));
```
```python Python / Flask theme={null}
from flask import Flask, request
import hmac
import hashlib
import json
import os
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
secret = os.environ.get('OPNFORM_WEBHOOK_SECRET')
if not signature or not secret:
return 'Unauthorized: Missing signature or secret', 401
# Get raw request body
raw_body = request.get_data()
# Calculate expected signature
expected_signature = 'sha256=' + hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(signature, expected_signature):
return 'Unauthorized: Invalid signature', 401
# Signature is valid, parse and process the webhook
data = request.get_json()
print('Webhook received:', data)
return 'Webhook received', 200
if __name__ == '__main__':
app.run(port=3000, debug=True)
```
```php PHP / Laravel theme={null}
header('X-Webhook-Signature');
$secret = env('OPNFORM_WEBHOOK_SECRET');
if (!$signature || !$secret) {
return response('Unauthorized: Missing signature or secret', 401);
}
// Get raw request body
$rawBody = $request->getContent();
// Calculate expected signature
$expectedSignature = 'sha256=' . hash_hmac(
'sha256',
$rawBody,
$secret
);
// Constant-time comparison
if (!hash_equals($signature, $expectedSignature)) {
return response('Unauthorized: Invalid signature', 401);
}
// Signature is valid, parse and process the webhook
$data = $request->json()->all();
\Log::info('Webhook received:', $data);
return response('Webhook received', 200);
});
```
```bash cURL Testing theme={null}
# Generate a webhook secret for testing
SECRET="whsec_1234567890abcdefghijklmnopqr"
BODY='{"form_title":"My Form","submission":{"email":"test@example.com"}}'
# Calculate signature
SIGNATURE="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)"
# Send webhook request
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-d "$BODY"
```
## Custom Headers
In addition to the signature header, OpnForm will send any custom headers you configured when creating the webhook. These can include authentication tokens, API keys, or other identifiers.
Example webhook request with custom headers:
```bash theme={null}
POST /webhook HTTP/1.1
Host: example.com
Content-Type: application/json
X-Webhook-Signature: sha256=abc123def456...
Authorization: Bearer my-api-token
X-Custom-Header: custom-value
{
"form_id": 123,
"form_title": "Contact Form",
"form_slug": "contact-form",
"submission_id": 456,
"edit_link": "https://opnform.com/forms/contact-form?submission_id=example",
"submission": { "Name": "Jane Doe" },
"data": {
"field-id": {
"value": "Jane Doe",
"name": "Name",
"type": "text"
}
},
"meta": {
"attribution": {
"utm_source": "newsletter",
"utm_campaign": "summer-launch"
}
}
}
```
The generic `"webhook"` integration keeps the legacy `submission` property for
backward compatibility. Provider-specific integrations such as `"make"` expose
the same `form_id`, `form_title`, `form_slug`, `submission_id`, optional
`edit_link`, and `data` properties while omitting the deprecated `submission`
and `message` properties.
When campaign parameters were captured, machine-oriented webhook integrations
also receive them under `meta.attribution`. The `meta` property is omitted when
the submission has no attribution.
## Security Best Practices
1. **Use HTTPS only**: Always use HTTPS endpoints for webhooks to prevent man-in-the-middle attacks
2. **Strong secrets**: Use cryptographically random secrets at least 12 characters long
3. **Constant-time comparison**: Use timing-safe comparison functions to prevent timing attacks
4. **Validate signatures first**: Verify the signature before parsing or processing the webhook data
5. **Store secrets securely**: Never commit secrets to version control; use environment variables or secret managers
6. **Rotate regularly**: Consider rotating your webhook secret periodically
7. **Log verification failures**: Track failed signature validations to detect potential attacks
## Troubleshooting
### Signature Mismatch
If you're consistently getting signature mismatches:
1. **Verify the raw body**: Ensure you're using the raw request body (before JSON parsing) to calculate the signature
2. **Check the secret**: Confirm you're using the exact secret from the webhook configuration
3. **Character encoding**: Ensure both the secret and body are handled with correct UTF-8 encoding
4. **Middleware order**: If using middleware, ensure raw body capture happens before JSON parsing
5. **Test with cURL**: Use the cURL example above to manually test signature generation
### Missing Signature Header
If the `X-Webhook-Signature` header is missing:
1. Verify you provided a `webhook_secret` when creating the webhook
2. Check your webhook status is `active`
3. Review the integration event logs for any errors during webhook delivery
## Testing Your Webhook
Use a webhook testing service like [webhook.site](https://webhook.site) or [RequestBin](https://requestbin.com) to inspect webhook requests during development. These services display headers, body, and other request details.
You can also implement local webhook testing by running a simple server on your machine and using a tool like [ngrok](https://ngrok.com) to expose it to the internet.
# Introduction
Source: https://docs.opnform.com/api-reference/introduction
Overview of the OpnForm REST API, base URL, authentication, and rate limits.
Welcome to the **OpnForm REST API** – a straightforward, JWT-based interface that lets you manage workspaces, forms and submissions from your own tools or backend jobs.
If you've ever used the dashboard you already know the objects you'll be working with: **workspaces**, **forms**, **submissions**, and **users**. The API exposes almost the same capabilities – you can list items, create new ones, update them or delete them, all with predictable HTTP verbs.
## Base URL
```text theme={null}
https://api.opnform.com
```
All endpoints documented in this reference are relative to that URL.
## Authentication
Every request must include a **Personal Access Token** in the `Authorization` header:
```http theme={null}
Authorization: Bearer
```
Tokens can be created in your OpnForm dashboard (see the [API Keys](./api-keys) section). Each token is associated with one or more **abilities / scopes** that restrict what it can do.
| Ability | Description |
| - | - |
| workspaces-read | Read workspaces the user belongs to |
| workspaces-write | Create, update or delete workspaces |
| workspace-users-read | List workspace members & invites |
| workspace-users-write | Manage workspace members & invites |
| forms-read | Read forms & submissions |
| forms-write | Create or modify forms & submissions |
When your request attempts an operation outside the abilities attached to the token, the API responds with **403 Forbidden**.
## Pagination
Some list endpoints are paginated. Use the `page` query parameter (`?page=2`) to retrieve subsequent pages.
## Rate limits
Each IP address is limited to **100 requests per minute**. Exceeding the limit returns **429 Too Many Requests**.
## Errors
Errors are returned in JSON with an HTTP status code that reflects the problem:
```json theme={null}
{
"message": "Validation failed.",
"errors": {
"title": ["The title field is required."]
}
}
```
See the status-code table below for the most common cases:
| Status | Meaning |
| - | - |
| 400 | Bad request / validation error |
| 401 | Missing or invalid token |
| 403 | Token lacks required ability |
| 404 | Resource not found |
| 429 | Rate limit exceeded |
| 500 | Server error – something went wrong on our side |
That's all you need to know to get started – head over to the endpoint sections or grab a token and make your first call:
```bash theme={null}
curl -H "Authorization: Bearer " https://api.opnform.com/open/workspaces
```
# Create Submission
Source: https://docs.opnform.com/api-reference/submissions/create-submission
post /forms/{slug}/answer
Create a new submission for a form. Public endpoint - no authentication required.
# Create Form Submission
Submit a new response to a form using the OpnForm API. This endpoint allows you to programmatically collect form data without requiring user authentication.
This is a public endpoint designed for form submissions. No API
authentication is required.
## Prerequisites
Before submitting to a form, you'll need:
* A published form with a valid slug or UUID
* Knowledge of the form's field IDs (available via the [Get Form](/api-reference/forms/get-form) endpoint)
* Form fields configured according to your validation requirements
You can find your form's slug in the OpnForm dashboard under form settings,
or use the form's UUID which is also displayed in the dashboard.
## Request
```http theme={null}
POST /forms/{slug}/answer HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
{
"completion_time": 10,
"tracking_parameters": {
"utm_source": "newsletter",
"utm_campaign": "summer-launch"
},
"3700d380-197b-47b9-a008-3acc31bbd506": "Alice",
"12461db5-0c19-429e-840b-8de1e359c42f": "alice@example.com"
}
```
### Path Parameters
The form identifier - either a human-readable slug (e.g.,
`customer-feedback`) or UUID. You can find this in your OpnForm dashboard
under form settings.
Optional campaign attribution. Supported keys are `utm_source`,
`utm_medium`, `utm_campaign`, `utm_id`, `utm_term`, `utm_content`,
`utm_source_platform`, `utm_creative_format`, `utm_marketing_tactic`,
`gclid`, `gbraid`, `wbraid`, `dclid`, `fbclid`, `ttclid`, and `msclkid`.
Values must be non-empty strings of at most 2,048 characters. Unsupported
keys are rejected. Attribution is stored separately from form answers and
cannot be changed after the submission is created.
### Request Body
**Dynamic field data**: Each form field is identified by its unique UUID. The value type depends on the field type:
* Text fields: `string`
* Number fields: `number`
* Checkbox fields: `boolean`
* Multi-select fields: `array`
**Example**: `"3700d380-197b-47b9-a008-3acc31bbd506": "Alice Johnson"`
Time in seconds it took the user to complete the form. Used for analytics
and form optimization insights.
Submit the form as a partial submission. Only works if the form has "Collect partial submissions" enabled in its settings.
When `true`, the response includes a `submission_hash` that can be used to update the same submission later.
## Response
```json Success (200 OK) theme={null}
{
"type": "success",
"message": "Form submission saved.",
"submission_id": "sub_1234567890",
"is_first_submission": true,
"redirect": false,
"submission_hash": null
}
```
```json Partial Success (200 OK) theme={null}
{
"type": "success",
"message": "Partial form submission saved.",
"submission_hash": "hash_abc123def456"
}
```
```json Validation Error (422) theme={null}
{
"message": "The Name field is required.",
"errors": {
"3700d380-197b-47b9-a008-3acc31bbd506": ["The Name field is required."],
"12461db5-0c19-429e-840b-8de1e359c42f": [
"The email field must be a valid email address."
]
}
}
```
```json Form Not Found (404) theme={null}
{
"message": "Form not found.",
"error": "The specified form slug does not exist or is not published."
}
```
### Success Response Fields
Response type indicator. Always `"success"` for successful submissions.
Human-readable success message describing the submission result.
Unique identifier for the created submission. Returns `null` for partial
submissions.
Indicates whether this is the first submission for this form. Useful for
triggering welcome flows or first-time user experiences.
Indicates if the form has a custom redirect URL configured. Always `false`
for API submissions.
Unique hash for partial submissions that can be used to update the
submission later. Only present when `is_partial: true`.
## Use Cases
### Basic Form Submission
Submit a complete contact form with validation:
```javascript theme={null}
const submitContactForm = async (formData) => {
try {
const response = await fetch(
"https://api.opnform.com/forms/contact-us/answer",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
"3700d380-197b-47b9-a008-3acc31bbd506": formData.name,
"12461db5-0c19-429e-840b-8de1e359c42f": formData.email,
"a8f5c2d1-9b7e-4c3f-8a1d-2e5f9c4b7a8e": formData.message,
completion_time: formData.timeSpent,
}),
}
);
if (!response.ok) {
throw new Error("Submission failed");
}
return await response.json();
} catch (error) {
console.error("Form submission error:", error);
throw error;
}
};
```
### Partial Submission Workflow
Save progress and complete later:
```javascript theme={null}
// Save partial submission
const saveProgress = async (partialData) => {
const response = await fetch(
"https://api.opnform.com/forms/survey/answer",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...partialData,
is_partial: true,
}),
}
);
const result = await response.json();
// Store submission_hash for later use
localStorage.setItem("submission_hash", result.submission_hash);
return result;
};
// Complete the submission later
const completeSubmission = async (finalData) => {
const hash = localStorage.getItem("submission_hash");
const response = await fetch(
"https://api.opnform.com/forms/survey/answer",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...finalData,
submission_hash: hash,
is_partial: false,
}),
}
);
return await response.json();
};
```
# Delete Submission
Source: https://docs.opnform.com/api-reference/submissions/delete-submission
delete /open/forms/{id}/submissions/{submission_id}
Requires `forms-write`.
# Delete Submission
Remove a single submission from a form.
## Authentication & Scope
Requires the `forms-write` ability.
## Request
```http theme={null}
DELETE /open/forms/{id}/submissions/{submission_id} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the parent form. |
| submission\_id | number | Numeric ID of the submission to permanently delete. |
## Response
`200 OK` – Returns a confirmation message.
```json theme={null}
{
"message": "Record successfully removed."
}
```
`403 Forbidden` – The token lacks `forms-write` or you don't have permission.
# Check Export Status
Source: https://docs.opnform.com/api-reference/submissions/export-status
get /open/forms/{id}/submissions/export/status/{job_id}
Check the status of an asynchronous export job. Requires `forms-read`.
# Check Export Status
Check the status and progress of an asynchronous CSV export job.
## Authentication & Scope
Requires `forms-read` ability.
## Request
```http theme={null}
GET /open/forms/{id}/submissions/export/status/{job_id} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the form. |
| job\_id | string | Export job identifier. |
## Response
### Processing Status
```json theme={null}
{
"status": "processing",
"progress": 65,
"processed_submissions": 650,
"total_submissions": 1000,
"file_url": null,
"error_message": null,
"expires_at": null,
"created_at": "2024-06-12T09:15:23.000Z",
"updated_at": "2024-06-12T09:16:10.000Z",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```
### Completed Status
```json theme={null}
{
"status": "completed",
"progress": 100,
"processed_submissions": 1000,
"total_submissions": 1000,
"file_url": "https://storage.example.com/exports/form-123-submissions-2024-06-12-09-15-23.csv",
"error_message": null,
"expires_at": "2024-06-13T09:15:23.000Z",
"created_at": "2024-06-12T09:15:23.000Z",
"updated_at": "2024-06-12T09:16:30.000Z",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```
### Failed Status
```json theme={null}
{
"status": "failed",
"progress": 0,
"processed_submissions": null,
"total_submissions": null,
"file_url": null,
"error_message": "Database connection timeout",
"expires_at": null,
"created_at": "2024-06-12T09:15:23.000Z",
"updated_at": "2024-06-12T09:15:45.000Z",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
```
## Response Fields
| Field | Type | Description |
| - | - | - |
| `status` | string | Job status: `queued`, `processing`, `completed`, or `failed` |
| `progress` | number | Completion percentage (0-100) |
| `job_id` | string | Export job identifier |
| `processed_submissions` | number | Number of submissions processed so far |
| `total_submissions` | number | Total number of submissions to process |
| `file_url` | string | Download URL for completed exports |
| `expires_at` | string | File expiration timestamp (workspace policy, 24 hours by default) |
| `error_message` | string | Error description for failed exports |
| `created_at` | string | Job creation timestamp |
| `updated_at` | string | Last update timestamp |
Every field is always present. Fields that do not apply to the current job
state are `null`.
The download URL is a bearer link. It remains valid until `expires_at`,
according to the workspace file-link policy (24 hours by default).
## Error Responses
`404 Not Found` – Export job not found, has expired, or belongs to another form.
`403 Forbidden` – The token lacks `forms-read` or you don't have access.
## Usage Example
After initiating an export that returns `is_async: true`, poll this endpoint to track progress:
Call the export endpoint and receive a `job_id`.
Use the `job_id` to check export progress every few seconds.
Avoid polling too frequently. Check every 2-5 seconds for optimal
performance.
When status is `completed`, use the `file_url` to download your CSV file.
Download the file before the `expires_at` timestamp. The workspace policy is 24 hours by default.
# Export Submissions (CSV)
Source: https://docs.opnform.com/api-reference/submissions/export-submissions-csv
post /open/forms/{id}/submissions/export
Export submissions as CSV. Large exports are processed asynchronously. Requires `forms-read`.
# Export Submissions (CSV)
Export submissions for a form as a CSV file. Large exports are processed asynchronously in the background, while smaller exports are processed immediately.
## Authentication & Scope
Requires `forms-read` ability.
## Request
```http theme={null}
POST /open/forms/{id}/submissions/export HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the form. |
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| columns | object | Yes | Keys are field IDs, system columns, or attribution columns such as `meta.attribution.utm_source`; values indicate whether to include them. |
Example payload exporting two fields and the created\_at timestamp:
```json theme={null}
{
"columns": {
"field_name": true,
"field_email": true,
"meta.attribution.utm_source": true,
"created_at": true
}
}
```
## Response
The API automatically determines whether to process the export synchronously or asynchronously based on the form size.
### Synchronous Export (Small Forms)
`200 OK` – Returns a CSV file download immediately. The response's `Content-Type` will be `text/csv` and include the `Content-Disposition` header.
### Asynchronous Export (Large Forms)
`200 OK` – Returns a job status object for background processing:
```json theme={null}
{
"message": "Export started. Large export will be processed in the background.",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"is_async": true
}
```
Use the `job_id` to check export progress using the [Export Status
endpoint](/api-reference/submissions/export-status).
### Error Responses
`403 Forbidden` – The token lacks `forms-read` or you don't have access.
# List Submissions
Source: https://docs.opnform.com/api-reference/submissions/list-submissions
get /open/forms/{id}/submissions
Requires `forms-read`. Supports pagination, search, and status filtering.
# List Submissions
Retrieve submissions for a specific form.
## Authentication & Scope
Requires `forms-read` ability.
## Request
```http theme={null}
GET /open/forms/{id}/submissions?page=1 HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | Numeric ID of the form. |
### Query Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| page | number | No | Pagination page number (default `1`). |
| per\_page | number | No | Number of results per page (default `100`, max `100`). |
| search | string | No | Search term to filter submissions by field values. |
| status | string | No | Filter by submission status: `completed`, `partial`, or `all` (default). |
## Response
`200 OK` – Returns a paginated collection of `Submission` objects.
```json theme={null}
{
"data": [
{
"data": {
"field-id-1": "Sample text response",
"field-id-2": ["Option 1", "Option 2"],
"field-id-3": [
{
"file_url": "https://api.opnform.com/open/forms/123/submissions/file/document.pdf?expires=1756808073&signature=abc123...",
"file_name": "document.pdf"
}
],
"status": "completed",
"created_at": "2024-06-12 09:15:23",
"id": 615432
},
"completion_time": 45,
"form_id": 123,
"id": 615432,
"meta": {
"attribution": {
"utm_source": "newsletter",
"utm_campaign": "summer-launch"
}
}
}
],
"links": {
"first": "https://api.opnform.com/open/forms/123/submissions?page=1",
"last": "https://api.opnform.com/open/forms/123/submissions?page=3",
"prev": null,
"next": "https://api.opnform.com/open/forms/123/submissions?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 3,
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "https://api.opnform.com/open/forms/123/submissions?page=1",
"label": "1",
"active": true
},
{
"url": "https://api.opnform.com/open/forms/123/submissions?page=2",
"label": "Next »",
"active": false
}
],
"path": "https://api.opnform.com/open/forms/123/submissions",
"per_page": 100,
"to": 12,
"total": 12
}
}
```
### Response Fields
Each submission object contains:
| Field | Type | Description |
| - | - | - |
| `data` | object | Contains form field responses plus metadata |
| `completion_time` | number/null | Time taken to complete the form in seconds |
| `form_id` | number | ID of the parent form |
| `id` | number | Unique submission ID |
| `meta` | object/null | Owner-only submission metadata, including campaign attribution when captured |
#### Data Object Structure
The `data` object contains:
* **Form field responses**: Keyed by field UUID, values vary by field type
* **Metadata fields**:
* `status`: Submission status (`completed` or `partial`)
* `created_at`: Submission creation timestamp
* `id`: Submission ID (duplicate for convenience)
#### File Field Handling
File and signature fields are automatically processed to include download URLs:
```json theme={null}
"field-uuid": [
{
"file_url": "https://api.opnform.com/open/forms/123/submissions/file/filename.pdf?expires=1756808073&signature=abc123...",
"file_name": "filename.pdf"
}
]
```
File URLs are signed and expire after 10 minutes for security.
### Query Parameter Details
#### Search Functionality
* Searches within submission field **values only** (not field names/keys)
* Uses case-insensitive matching
* Supports partial text matching
#### Status Filtering
* `completed`: Returns only fully completed submissions
* `partial`: Returns only partially completed submissions (Pro feature)
* `all` or omitted: Returns all submissions regardless of status
#### Pagination
* Default `per_page` is 100, maximum is 100
* Results are ordered by `created_at` in descending order (newest first)
`403 Forbidden` – The token lacks `forms-read`.
# Update Submission
Source: https://docs.opnform.com/api-reference/submissions/update-submission
put /open/forms/{id}/submissions/{submission_id}
Update a submission. Requires `forms-write` ability.
# Update Submission
Modify an existing submission's answers. This endpoint is useful when you want to correct values or enrich a submission after it was created.
## Authentication & Scope
Requires `forms-write` ability.
## Request
```http theme={null}
PUT /open/forms/{id}/submissions/{submission_id} HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| id | number | ID of the parent form. |
| submission\_id | number | ID of the submission you want to update. |
### Body Parameters
The payload should be an object whose keys correspond to the **field IDs** in the form definition. You only need to send the fields you intend to change. Other special meta-fields:
| Field | Type | Description |
| - | - | - |
| completion\_time | integer | Optional; total time in seconds. |
Example (updating two answers):
```json theme={null}
{
"field_name": "Alice",
"field_email": "alice@example.com",
"completion_time": 125
}
```
## Response
`200 OK` – Returns the updated `Submission` object plus a success message.
```json theme={null}
{
"message": "Record successfully updated.",
"data": {
"submission_id": 17,
"form_id": 99,
"submitted_at": "2024-06-12T10:11:12Z",
"data": {
"field_name": "Alice",
"field_email": "alice@example.com"
}
}
}
```
`403 Forbidden` – Token lacks `forms-write` or you do not have permission.
# Add Workspace User
Source: https://docs.opnform.com/api-reference/workspace-users/add-workspace-user
post /open/workspaces/{workspaceId}/users/add
Requires `workspace-users-write`. Add an existing user or send an invite if the email is unknown. Self-hosted Community instances are limited to 2 users total across the instance; adding or inviting more users requires a self-hosted Enterprise license.
# Add Workspace User
Invite an existing OpnForm user to a workspace or send an email invite if the user doesn't yet have an account.
Self-hosted Community instances are limited to 2 users total across the whole instance. Adding or inviting a new user beyond that limit requires a self-hosted Enterprise license. Adding an existing instance user to another workspace does not consume an additional user seat.
## Authentication & Scope
Requires the `workspace-users-write` ability and **admin** privileges in the workspace.
## Request
```http theme={null}
POST /open/workspaces/{workspaceId}/users/add HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the target workspace. |
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| email | string | Yes | Email address of the user to add/invite. |
| role | string | Yes | Role to assign (`admin`, `user`, or `readonly`). |
Example:
```json theme={null}
{
"email": "jane@example.com",
"role": "user"
}
```
## Response
`200 OK` – One of the following messages:
* `"User has been successfully added to workspace."` – when the user account already exists.
* `"Registration invitation email sent to user."` – when an invitation email was sent.
* `"User is already in workspace."` – if the user is already a member.
The response structure:
```json theme={null}
{
"message": "User has been successfully added to workspace."
}
```
`403 Forbidden` – Token lacks `workspace-users-write`, the requester has insufficient privileges, or the self-hosted Community instance has reached the 2-user limit.
# Cancel Workspace Invite
Source: https://docs.opnform.com/api-reference/workspace-users/cancel-workspace-invite
delete /open/workspaces/{workspaceId}/invites/{inviteId}/cancel
Requires `workspace-users-write`. Delete a pending invite.
# Cancel Workspace Invite
Delete an outstanding workspace invitation.
## Authentication & Scope
Requires `workspace-users-write` ability and **admin** privileges in the workspace.
## Request
```http theme={null}
DELETE /open/workspaces/{workspaceId}/invites/{inviteId}/cancel HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace. |
| inviteId | number | ID of the invite to cancel. |
## Response
`200 OK` – Confirmation message.
```json theme={null}
{
"message": "Invite deleted successfully."
}
```
Possible errors:
* `Invite not found for this workspace.`
* `Invite already accepted.`
Both are returned with status **400**.
`403 Forbidden` – Token lacks `workspace-users-write` or insufficient privileges.
# Leave Workspace
Source: https://docs.opnform.com/api-reference/workspace-users/leave-workspace
post /open/workspaces/{workspaceId}/leave
Allows the current user to leave the workspace. Requires `workspace-users-write`.
# Leave Workspace
Remove **your own** user account from a workspace.
## Authentication & Scope
Requires the `workspace-users-write` ability. Only the authenticated user can leave; admins cannot use this endpoint to kick others (see [Remove Workspace User](./remove-workspace-user)).
## Request
```http theme={null}
POST /open/workspaces/{workspaceId}/leave HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace to quit |
Request body is empty.
## Response
`204 No Content` – You successfully left the workspace.
`403 Forbidden` – Token lacks `workspace-users-write` or you aren't a member.
# List Workspace Invites
Source: https://docs.opnform.com/api-reference/workspace-users/list-workspace-invites
get /open/workspaces/{workspaceId}/invites
Requires workspace admin access. Personal Access Tokens must include `workspaces-write` and belong to a workspace admin. Lists pending & accepted invites.
# List Workspace Invites
Retrieve all pending or accepted invitations for a workspace.
## Authentication & Scope
Requires workspace admin access.
For Personal Access Tokens, the token must include the `workspaces-write` ability and the token owner must be an admin of the workspace. Non-admin workspace members and tokens with only `workspace-users-read` cannot list invites.
## Request
```http theme={null}
GET /open/workspaces/{workspaceId}/invites HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace. |
## Response
`200 OK` – Array of Invite objects.
```json theme={null}
[
{
"id": 55,
"email": "invitee@example.com",
"role": "user",
"status": "pending",
"valid_until": "2024-06-25T23:59:59Z"
}
]
```
`403 Forbidden` – You are not a workspace admin, or your token lacks `workspaces-write`.
# List Workspace Users
Source: https://docs.opnform.com/api-reference/workspace-users/list-workspace-users
get /open/workspaces/{workspaceId}/users
Requires `workspace-users-read`. Returns members with their role.
# List Workspace Users
Fetch all members of a workspace, including their assigned role.
## Authentication & Scope
Requires `workspace-users-read` ability. The requesting user must belong to the workspace.
## Request
```http theme={null}
GET /open/workspaces/{workspaceId}/users HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace to retrieve. |
No query parameters are supported.
## Response
`200 OK` – An array of user objects, each including a `role` field (from the pivot table).
```json theme={null}
[
{
"id": 3,
"name": "Alice Porter",
"email": "alice@example.com",
"role": "admin"
},
{
"id": 8,
"name": "Bob Smith",
"email": "bob@example.com",
"role": "member"
}
]
```
`403 Forbidden` – Token lacks `workspace-users-read` or you are not a member.
# Remove Workspace User
Source: https://docs.opnform.com/api-reference/workspace-users/remove-workspace-user
delete /open/workspaces/{workspaceId}/users/{userId}/remove
Requires `workspace-users-write`. Removes a specified user from the workspace.
# Remove Workspace User
Kick a member out of a workspace. If the removed user has no remaining workspaces, a personal workspace will be automatically created.
## Authentication & Scope
Requires the `workspace-users-write` ability and **admin** privileges in the workspace.
## Request
```http theme={null}
DELETE /open/workspaces/{workspaceId}/users/{userId}/remove HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace. |
| userId | number | ID of the user to remove. |
## Response
`200 OK` – Confirmation message.
```json theme={null}
{
"message": "User removed from workspace successfully."
}
```
`403 Forbidden` – Token lacks `workspace-users-write` or insufficient privileges.
# Resend Workspace Invite
Source: https://docs.opnform.com/api-reference/workspace-users/resend-workspace-invite
post /open/workspaces/{workspaceId}/invites/{inviteId}/resend
Requires `workspace-users-write`. Resend the invitation email for a pending invite.
# Resend Workspace Invite
Send the invitation email again to a pending workspace invite.
## Authentication & Scope
Requires `workspace-users-write` ability and **admin** privileges in the workspace.
## Request
```http theme={null}
POST /open/workspaces/{workspaceId}/invites/{inviteId}/resend HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace. |
| inviteId | number | ID of the invite to resend. |
Request body is empty.
## Response
`200 OK` – Confirmation message.
```json theme={null}
{
"message": "Invite email resent successfully."
}
```
Possible errors:
* `Invite not found for this workspace.`
* `Invite already accepted.`
Both are returned with status **400** (wrapped in the `errors` format).
`403 Forbidden` – Token lacks `workspace-users-write` or insufficient privileges.
# Update Workspace User Role
Source: https://docs.opnform.com/api-reference/workspace-users/update-workspace-user-role
put /open/workspaces/{workspaceId}/users/{userId}/update-role
Requires `workspace-users-write`. Update a member's role.
# Update Workspace User Role
Change a member's role within a workspace.
## Authentication & Scope
Requires the `workspace-users-write` ability and **admin** privileges in the workspace.
## Request
```http theme={null}
PUT /open/workspaces/{workspaceId}/users/{userId}/update-role HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace. |
| userId | number | ID of the user to update. |
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| role | string | Yes | New role (`admin`, `user`, or `readonly`). |
Example:
```json theme={null}
{
"role": "readonly"
}
```
## Response
`200 OK` – Confirmation message.
```json theme={null}
{
"message": "User role changed successfully."
}
```
`403 Forbidden` – Token lacks `workspace-users-write` or you lack permission.
# Create Workspace
Source: https://docs.opnform.com/api-reference/workspaces/create-workspace
post /open/workspaces/create
Requires `workspaces-write`.
# Create Workspace
Create a new workspace and automatically add the current user as an **admin** of that workspace.
## Authentication & Scope
Requires the `workspaces-write` ability.
## Request
```http theme={null}
POST /open/workspaces/create HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| name | string | Yes | Display name of the workspace. |
| emoji | string | No | Optional emoji/icon to represent it. |
Example:
```json theme={null}
{
"name": "Design Team",
"emoji": "🎨"
}
```
## Response
`200 OK` – Returns the newly created workspace information.
```json theme={null}
{
"message": "Workspace created.",
"workspace_id": 7,
"workspace": {
"id": 7,
"name": "Design Team",
"icon": "🎨",
"settings": {},
"max_file_size": 25,
"is_readonly": false
}
}
```
`403 Forbidden` – Token lacks `workspaces-write` or user not allowed.
# Delete Workspace
Source: https://docs.opnform.com/api-reference/workspaces/delete-workspace
delete /open/workspaces/{workspaceId}
Permanently delete a workspace. Requires `workspaces-write`.
# Delete Workspace
Permanently remove a workspace. This operation is irreversible and will remove all associated forms, submissions, and settings.
## Authentication & Scope
Requires the `workspaces-write` ability. User must be an **admin** of the workspace.
## Request
```http theme={null}
DELETE /open/workspaces/{workspaceId} HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | ID of the workspace to delete. |
## Response
`200 OK` – Confirmation message and the ID of the deleted workspace.
```json theme={null}
{
"message": "Workspace deleted.",
"workspace_id": 12
}
```
`403 Forbidden` – The token lacks `workspaces-write` or you lack permission.
# List Workspaces
Source: https://docs.opnform.com/api-reference/workspaces/list-workspaces
get /open/workspaces
Requires `workspaces-read`.
# List Workspaces
Return the workspaces the authenticated user belongs to.
## Authentication & Scope
Requires the `workspaces-read` ability.
## Request
```http theme={null}
GET /open/workspaces HTTP/1.1
Host: api.opnform.com
Authorization: Bearer
```
No query parameters are supported.
## Response
`200 OK` – An array of Workspace objects.
```json theme={null}
[
{
"id": 1,
"name": "Marketing",
"icon": "🚀",
"settings": {
"email_settings": {
"from_name": "OpnForm"
}
},
"max_file_size": 25,
"is_readonly": false
}
]
```
`403 Forbidden` – The token lacks `workspaces-read` or you do not have the necessary permissions.
# Update Workspace
Source: https://docs.opnform.com/api-reference/workspaces/update-workspace
put /open/workspaces/{workspaceId}
Update workspace name or icon. Requires `workspaces-write`.
# Update Workspace
Rename a workspace or change its icon.
## Authentication & Scope
Requires the `workspaces-write` ability. The authenticated user must be an **admin** of the workspace.
## Request
```http theme={null}
PUT /open/workspaces/{workspaceId} HTTP/1.1
Host: api.opnform.com
Content-Type: application/json
Authorization: Bearer
```
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| workspaceId | number | Numeric identifier of the workspace. |
### Body Parameters
| Field | Type | Required | Description |
| - | - | - | - |
| name | string | Yes | New name of the workspace. |
| emoji | string | No | New emoji/icon (leave blank to clear). |
Example:
```json theme={null}
{
"name": "Marketing Team",
"emoji": "🚀"
}
```
## Response
`200 OK` – Returns a confirmation plus the updated workspace object.
```json theme={null}
{
"message": "Workspace updated.",
"workspace": {
"id": 1,
"name": "Marketing Team",
"icon": "🚀",
"max_file_size": 25,
"is_readonly": false
}
}
```
`403 Forbidden` – Token lacks `workspaces-write` or user lacks permission.
# List Forms
Source: https://docs.opnform.com/api-reference/zapier/list-forms
get /external/zapier/forms
Retrieve a list of forms available in a specific workspace.
# New Submission Trigger
Source: https://docs.opnform.com/api-reference/zapier/new-submission-trigger
post /external/zapier/webhook
This endpoint is used to set up a webhook for new form submissions.
# Sample Submission Polling
Source: https://docs.opnform.com/api-reference/zapier/sample-submission-polling
get /external/zapier/submissions/recent
Retrieves the most recent submissions for a specified form.
# Unsubscribe Webhook
Source: https://docs.opnform.com/api-reference/zapier/unsubscribe-webhook
delete /external/zapier/webhook
This endpoint is used to unsubscribe from the webhook.
# Validate API Key
Source: https://docs.opnform.com/api-reference/zapier/validate-api-key
get /external/zapier/validate
This endpoint is used by Zapier to test the validity of the API key.
# AWS S3 Configuration
Source: https://docs.opnform.com/configuration/aws-s3
OpnForm & File uploads
OpnForm uses [Laravel's filesystem](https://laravel.com/docs/master/filesystem), which provides a powerful abstraction layer for various file storage systems. While AWS S3 is a popular choice, OpnForm supports both local storage and S3-compatible services.
This guide covers instance-wide file storage configuration for your
self-hosted deployment. Enterprise external storage features are separate
workspace or organization-level controls. See [External
Storage](/deployment/enterprise-features/external-storage).
1. Create an S3 bucket in your AWS account (or use an equivalent service).
2. Create an IAM user with access to this bucket. Ensure the user has the necessary permissions to read from and write to the bucket.
3. Configure CORS for the S3 bucket permissions, by adding the following under "Cross-origin resource sharing (CORS)":
```json theme={null}
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET", "DELETE"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
```
4. Set the following environment variables in your OpnForm installation (`api/.env`):
* `AWS_ACCESS_KEY_ID`: Your IAM user's access key ID
* `AWS_SECRET_ACCESS_KEY`: Your IAM user's secret access key
* `AWS_DEFAULT_REGION`: The AWS region where your S3 bucket is located
* `AWS_BUCKET`: The name of your S3 bucket
These settings will enable OpnForm to use your S3 bucket for file storage.
# Using your own domain
Source: https://docs.opnform.com/configuration/custom-domain
Setting up a custom domain for OpnForm
This page explains how to configure a custom domain for your OpnForm instance.
This guide covers the instance-wide domain for your self-hosted OpnForm
deployment. Workspace or form-level custom domains are configured
separately inside OpnForm and are not the same as the instance URL.
## Custom Domain Setup
To use a custom domain (or subdomain) with OpnForm:
1. Purchase a domain (if needed)
2. Configure DNS records to point to your OpnForm instance
3. Update an existing Docker installation with your public origin:
```bash theme={null}
./scripts/docker-setup.sh --public-url https://forms.example.com
```
The command updates the following configuration while preserving existing
keys and secrets:
* Set both `APP_URL` and `FRONT_URL` in `api/.env`
* Set `NUXT_PUBLIC_APP_URL` and `NUXT_PUBLIC_API_BASE` in `client/.env`
4. Secure with SSL certificate
For example, when the public URL is `https://forms.example.com`:
```bash theme={null}
# api/.env
APP_URL=https://forms.example.com
FRONT_URL=https://forms.example.com
# client/.env
NUXT_PUBLIC_APP_URL=https://forms.example.com
NUXT_PUBLIC_API_BASE=https://forms.example.com/api
```
`FRONT_URL` is used by the backend when it creates frontend callback URLs,
including OIDC callbacks. Keep it aligned with the public frontend URL.
If you change these files manually, recreate the containers; a restart alone
does not load new environment values. See [Updating Environment Variables](./environment-variables#updating-environment-variables).
If an OIDC connection already exists, edit it afterwards, use **Use current
app URL** to refresh its redirect URI, save it, and update the URI in your
identity provider. Existing connections retain their previous redirect URI
until you save a new one.
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
# Disable Two-Factor Authentication
Source: https://docs.opnform.com/configuration/disable-2fa
Administrative command to disable two-factor authentication for users
The `user:disable-two-factor` command allows administrators to disable two-factor authentication (2FA) for users who have lost access to their authenticator app or recovery codes.
This command should only be used in exceptional circumstances. All usage is logged for security auditing purposes.
## Command Signature
```bash theme={null}
php artisan user:disable-two-factor {user_email} {reason} [--force] [--allow-admin]
```
| Parameter/Option | Description |
| - | - |
| `user_email` | The email address of the user (case-insensitive) |
| `reason` | Reason for disabling 2FA (required for audit logging) |
| `--force` | Skip confirmation prompt (non-admin users only) |
| `--allow-admin` | Allow disabling 2FA for admin users (always requires interactive confirmation) |
## Usage Examples
```bash theme={null}
# Standard usage (with confirmation prompt)
php artisan user:disable-two-factor user@example.com "User lost access to authenticator app"
# Non-interactive mode
php artisan user:disable-two-factor user@example.com "Automated recovery" --force
# For admin users (requires confirmation, cannot use --force)
php artisan user:disable-two-factor admin@example.com "Emergency access" --allow-admin
```
## Running in Docker
```bash theme={null}
# Non-interactive
docker compose exec api php artisan user:disable-two-factor user@example.com "Lost authenticator" --force
# Interactive (for admin users)
docker compose exec -it api php artisan user:disable-two-factor admin@example.com "Emergency" --allow-admin
```
```bash theme={null}
docker exec php artisan user:disable-two-factor user@example.com "Lost authenticator" --force
```
Use `-it` flags for interactive commands (without `--force`) to allow the confirmation prompt.
## Security Notes
* All operations are logged to the Slack admin channel with user ID, email, reason, and admin override status
* Admin users require `--allow-admin` flag and cannot bypass confirmation with `--force`
* Users who can access their authenticator should disable 2FA through their account settings instead
# Email Setup
Source: https://docs.opnform.com/configuration/email-setup
OpnForm & Emails
This page explains how to configure email notifications in OpnForm.
By default, with a new Docker deployment, the email driver used is `log`, which simply logs the emails instead of sending them. To enable actual email sending, you need to set up a proper email configuration.
OpnForm uses Laravel's mail system. For more detailed information, you can refer to the [Laravel Mail documentation](https://laravel.com/docs/master/mail).
## Email Configuration Options
Self-hosted OpnForm has two email configuration levels:
* **Instance-wide email sending**: Configure the default mailer for your whole OpnForm instance with environment variables such as `MAIL_MAILER`, `MAIL_HOST`, and `MAIL_FROM_ADDRESS`. This is the standard setup for sending account emails, notifications, and any form email notification that does not use workspace-specific SMTP settings.
* **Workspace email settings**: Configure a custom SMTP server from a workspace's **Email Settings** page. These settings apply only to that workspace and override the instance-wide mailer for that workspace's form email notifications. Workspace email settings are a self-hosted Enterprise feature.
You do not need workspace email settings to send emails from a self-hosted
instance. Configure the instance-wide `MAIL_*` variables first, then use
workspace email settings only when a workspace needs its own SMTP server or
sender identity. See [Workspace Custom
SMTP](/deployment/enterprise-features/workspace-custom-smtp) for details.
## Supported Mail Drivers
Laravel supports various mail drivers, including SMTP, Mailgun, Postmark, Amazon SES, and more. In this guide, we'll focus on setting up SMTP, which is widely used.
## Configure Instance-Wide SMTP Settings
To configure SMTP for the whole instance, set the following environment variables in your backend `.env` file:
| Variable | Description |
| - | - |
| `MAIL_MAILER` | Set this to `smtp` to use the SMTP driver. |
| `MAIL_HOST` | The address of your SMTP server. |
| `MAIL_PORT` | The port used by your SMTP server (common ports are 25, 465, and 587). |
| `MAIL_USERNAME` | The username for your SMTP account. |
| `MAIL_PASSWORD` | The password for your SMTP account. |
| `MAIL_ENCRYPTION` | The encryption method used by your SMTP server (typically `tls` or `ssl`). |
| `MAIL_FROM_ADDRESS` | The email address that emails should be sent from. |
| `MAIL_FROM_NAME` | The name that should appear as the sender of the emails. |
After changing these variables, restart the API container or process so Laravel loads the new mail configuration.
## Configure Workspace SMTP Settings
Workspace email settings let a workspace send its form email notifications through a dedicated SMTP server instead of the instance-wide mailer.
Use workspace email settings when:
* A customer or internal team needs emails to come from their own domain.
* Different workspaces need different SMTP credentials.
* You want form notifications for one workspace to use a separate sender reputation or provider.
To configure workspace SMTP settings:
Open the workspace where you want to use a custom sender, then go to **Email Settings**.
Add the SMTP host, port, encryption, username, password, and sender address for that workspace.
Save the workspace email settings. Form email notifications from this workspace will use this SMTP server when the workspace has access to the feature.
Workspace email settings do not replace the instance-wide mail
configuration. Keep the `MAIL_*` variables configured as the default sender
and fallback for your instance. Workspace email settings require a
self-hosted Enterprise license.
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
# Environment Variables
Source: https://docs.opnform.com/configuration/environment-variables
Detailed guide on configuring environment variables for OpnForm
OpnForm uses two `.env` files for configuration: one for the Laravel backend located in the `api` directory, and one for the Nuxt front-end located in the `client` directory.
## Public instance URL (self-hosted)
Set these variables before exposing a self-hosted instance publicly or
configuring OIDC, OAuth, emails, or any feature that generates links back to
your OpnForm instance. They must use the real public URL, never `localhost` in
production.
For a standard Docker deployment available at `https://forms.example.com`:
```bash theme={null}
./scripts/docker-setup.sh --public-url https://forms.example.com
```
The setup command creates or updates the following values while preserving
existing application keys and secrets:
| File | Variable | Value |
| - | - | - |
| `api/.env` | `APP_URL` | `https://forms.example.com` |
| `api/.env` | `FRONT_URL` | `https://forms.example.com` |
| `client/.env` | `NUXT_PUBLIC_APP_URL` | `https://forms.example.com` |
| `client/.env` | `NUXT_PUBLIC_API_BASE` | `https://forms.example.com/api` |
`APP_URL`, `FRONT_URL`, and `NUXT_PUBLIC_APP_URL` normally have the same
public origin. In the standard Docker setup, the API is served through that
same origin under `/api`, which is why `NUXT_PUBLIC_API_BASE` ends in `/api`.
If your API is exposed on a separate public origin, use that browser-accessible
API URL instead.
If you configure these values manually, recreate the affected containers.
`docker compose restart` does not reload environment values. See
[Updating Environment Variables](#updating-environment-variables).
For the deployment workflow, see [Docker Deployment](../deployment/docker#configure-your-public-instance-url).
For the OIDC-specific checklist, see [OIDC SSO Configuration](./oidc-sso#prerequisites).
## Backend Environment Variables
The following environment variables are used to [configure the Laravel](https://laravel.com/docs/11.x/configuration) application (OpnForm's API).
### Dedicated guides
There are dedicated configuration pages available for more detailed setup instructions on specific topics:
* [OAuth Integration Setup](../configuration/oauth-setup)
* [OIDC SSO Configuration](../configuration/oidc-sso)
* [File Storage (S3)](../configuration/aws-s3)
* [Email Configuration (SMTP)](../configuration/email-setup)
* [Custom Domain](../configuration/custom-domain)
* [Subdomain Redirect Configuration](../configuration/subdomain-redirect)
### Configuration Environment Variables
| Variable Name | Description |
| - | - |
| `H_CAPTCHA_SITE_KEY` | Site key for hCaptcha integration. |
| `H_CAPTCHA_SECRET_KEY` | Secret key for hCaptcha integration. |
| `RE_CAPTCHA_SITE_KEY` | Site key for reCAPTCHA integration. |
| `RE_CAPTCHA_SECRET_KEY` | Secret key for reCAPTCHA integration. |
| `OPEN_AI_API_KEY` | API key for accessing OpenAI services. |
| `UNSPLASH_ACCESS_KEY` | Access key for Unsplash API. |
| `UNSPLASH_SECRET_KEY` | Secret key for Unsplash API. |
| `APP_URL` | Public URL of the Laravel application. For a standard self-hosted deployment, set it to the public OpnForm URL. |
| `FRONT_URL` | Public facing URL of the front-end. |
| `FRONT_API_SECRET` | Shared secret with the front-end. |
| `JWT_TTL` | Time to live for JSON Web Tokens (JWT). |
| `JWT_SECRET` | Secret key used to sign JWTs. |
| `JWT_SKIP_IP_UA_VALIDATION` | Set to `true` to disable JWT User Agent validation (defaults to `false`). Keep this disabled in production and Docker deployments. For local Docker development, `docker-compose.dev.yml` sets this to `true` automatically. |
| `OAUTH_ENABLED` | Enables the delegated OAuth 2.1 infrastructure. Defaults to `true`; set it to `false` only when you need to disable OAuth routes independently of the MCP setting. |
| `OAUTH_ACCESS_TOKEN_TTL` | Passport access token lifetime in minutes. Defaults to `10080` (7 days) and does not change the first-party JWT lifetime. |
| `OAUTH_REFRESH_TOKEN_TTL_DAYS` | Passport refresh token lifetime in days. Defaults to `30`. |
| `PUBLIC_UPLOADS_RATE_LIMIT_PER_MINUTE` | Maximum public upload requests allowed per client IP or authenticated user per route each minute. Defaults to `30`. |
| `PUBLIC_UPLOADS_RATE_LIMIT_PER_HOUR` | Maximum public upload requests allowed per client IP or authenticated user per route each hour. Defaults to `300`. |
| `OIDC_FORCE_LOGIN` | When set to `true`, password-based authentication is disabled and users must authenticate via OIDC SSO. See [Force Login Mode](../configuration/oidc-sso#7-force-login-mode) for details. |
| `OIDC_RATE_LIMIT_PER_MINUTE` | Maximum OIDC sign-in initiations allowed per client IP and OIDC connection each minute. Defaults to `100`. OIDC callbacks use the normal API limit and are protected by their single-use state verifier. |
| `TRUSTED_PROXIES` | Comma-separated IP addresses or CIDR ranges of reverse proxies allowed to provide `X-Forwarded-*` headers. Leave empty for a direct Docker deployment. Never use `*`; it would allow a direct client to spoof its IP. |
| `CUSTOM_CODE_ENABLE_SELF_HOSTED` | Enables execution of user-provided custom code on self-hosted deployments. Defaults to `false` for safety reasons (XSS etc.). |
| `LICENSE_API_ENDPOINT` | Cloud API endpoint used by self-hosted instances for Enterprise license validation. Defaults to `https://api.opnform.com`. |
| `LICENSE_CHECKOUT_SUCCESS_URL` | Cloud checkout success URL used when creating self-hosted Enterprise license Stripe sessions. |
| `LICENSE_CHECKOUT_CANCEL_URL` | Cloud checkout cancellation URL used when creating self-hosted Enterprise license Stripe sessions. |
| `LICENSE_PORTAL_RETURN_URL` | Return URL used by the Stripe billing portal for self-hosted Enterprise licenses. |
### Self-hosted Enterprise licensing
Self-hosted Enterprise features are unlocked by activating a license key in the OpnForm UI. Most installations can use the default license API endpoint.
Use the `LICENSE_*` variables only when you need to override checkout, billing portal, or license API URLs for your deployment.
For activation steps and troubleshooting, see [License Activation](../deployment/license-activation).
### OAuth Integration Environment Variables
OAuth integrations enable user authentication and third-party service connections. OpnForm automatically handles OAuth redirect URLs - you don't need to manually configure redirect URIs in your OAuth applications.
For detailed setup instructions for each OAuth provider, see the [OAuth
Integration Setup](../configuration/oauth-setup) guide.
| Variable Name | Description |
| - | - |
| `GOOGLE_CLIENT_ID` | Client ID for Google OAuth authentication and Google Sheets integration. |
| `GOOGLE_CLIENT_SECRET` | Client secret for Google OAuth authentication and Google Sheets integration. |
| `GOOGLE_FONTS_API_KEY` | API key for accessing Google Fonts (optional). |
| `STRIPE_CLIENT_ID` | Client ID for Stripe Connect OAuth integration for payment processing. |
| `STRIPE_CLIENT_SECRET` | Client secret for Stripe Connect OAuth integration. |
| `TELEGRAM_BOT_TOKEN` | Authentication token for your Telegram bot notifications (bot ID is extracted automatically). |
### User Options Environment Variables
| Variable Name | Description |
| - | - |
| `ADMIN_EMAILS` | Comma-separated list of admin email addresses. |
| `TEMPLATE_EDITOR_EMAILS` | Comma-separated list of template editor emails. |
| `EXTRA_PRO_USERS_EMAILS` | Comma-separated list of extra pro user emails. |
| `MODERATOR_EMAILS` | Comma-separated list of moderator email addresses. |
| `SHOW_OFFICIAL_TEMPLATES` | Set to `false` to hide official templates from OpnForm's template gallery (defaults to `true`). |
### PHP Configuration Environment Variables
| Variable Name | Description |
| - | - |
| `PHP_MEMORY_LIMIT` | Maximum amount of memory a script may consume. |
| `PHP_MAX_EXECUTION_TIME` | Maximum time in seconds a script is allowed to run. |
| `PHP_UPLOAD_MAX_FILESIZE` | Maximum size of an uploaded file. |
| `PHP_POST_MAX_SIZE` | Maximum size of POST data that PHP will accept. |
These PHP configuration variables are particularly important when handling file uploads and processing large amounts of data. If any of these variables are not set, PHP will use its default values from the php.ini configuration. When using Docker, these values are set in the docker-compose.yml file. For non-Docker installations, you'll need to configure these values in your PHP configuration (php.ini) file.
### Database Configuration Environment Variables
These variables are essential for connecting the Laravel backend to your database.
| Variable Name | Description |
| - | - |
| `DB_CONNECTION` | The database driver (e.g., `mysql`, `pgsql`). |
| `DB_HOST` | The database server host (e.g., `127.0.0.1`). |
| `DB_PORT` | The database server port (e.g., `3306`). |
| `DB_DATABASE` | The name of the database. |
| `DB_USERNAME` | The username for connecting to the database. |
| `DB_PASSWORD` | The password for the database user. |
### Anonymous Telemetry
OpnForm tracks usage of its products which helps the team to improve and deliver better software. You can opt-out of this telemetry at any time.
**What data is collected?**
None of your customer data is ever transmitted by OpnForm installation. The telemetry data collected are purely usage statistics of various software features:
* Basic usage metrics (form creation, submissions, workspace creation, user additions)
* Anonymous instance identifier (UUID)
* Distribution of forms by type
* No personally identifiable information (PII)
* No form content, submission data, or user emails
**How is the data used?**
This data will only be used by OpnForm team for:
* Directly improving the product and identifying the parts of product which are popular
* Identifying areas with limited usage or where our users get stuck
* Prioritizing the next set of features to be developed
**When is telemetry active?**
* Production environments (`APP_ENV=production`)
* Self-hosted deployments (`SELF_HOSTED=true`)
* Disabled in local/development environments
**How to opt-out:**
Set `OPNFORM_ANONYMOUS_TELEMETRY_DISABLED=true` in your environment variables to disable data collection.
### MCP and AI agents
MCP is disabled by default on self-hosted instances. Enable it from **Settings → MCP & AI agents** after configuring public HTTPS origins and Laravel Passport. Form and submission tools require OAuth. See the [MCP integration guide](../integrations/mcp) for the complete setup.
| Variable Name | Default | Description |
| - | - | - |
| `MCP_ENABLED` | `false` | Default MCP state for self-hosted instances. Set it in `api/.env`, the API service `environment` block in Docker Compose, or the API container environment. A value saved in **Settings → MCP & AI agents** overrides it. |
| `MCP_OBSERVABILITY_ENABLED` | `true` | Records metadata-only MCP usage events and structured logs. No arguments, tokens, form content, or submission values are recorded. |
| `MCP_RATE_LIMIT_PER_MINUTE` | `120` | MCP requests allowed per minute per OAuth user or unauthenticated client IP. |
| `MCP_RATE_LIMIT_PER_HOUR` | `3000` | MCP requests allowed per hour per OAuth user or unauthenticated client IP. |
| `MCP_SUBMISSION_EXPORTS_PER_MINUTE` | `5` | Submission exports allowed per minute per user and form. |
| `MCP_SUBMISSION_EXPORTS_PER_HOUR` | `30` | Submission exports allowed per hour per user and form. |
| `FORM_SUMMARY_RATE_LIMIT_PER_MINUTE` | `30` | REST and MCP submission summary requests allowed per minute in one shared per-user bucket. |
| `MCP_OAUTH_REDIRECT_DOMAINS` | See `.env.example` | Comma-separated HTTPS or loopback callback origins accepted during dynamic client registration. |
| `MCP_OAUTH_CUSTOM_SCHEMES` | `chatgpt,claude,cursor,vscode` | Comma-separated callback URI schemes accepted for trusted native clients. |
| `MCP_AUTHORIZATION_SERVER` | Application URL | Optional external OAuth authorization-server URL advertised by MCP discovery. |
## Front-end Environment Variables
| Variable Name | Description |
| - | - |
| `NUXT_PUBLIC_APP_URL` | Public facing URL of the Nuxt application. |
| `NUXT_PUBLIC_API_BASE` | Base URL for the Laravel API. |
| `NUXT_PUBLIC_LICENSE_API_ENDPOINT` | Cloud API endpoint used by the self-hosted client to start Enterprise license checkout. Defaults to `https://api.opnform.com`. |
| `NUXT_PUBLIC_H_CAPTCHA_SITE_KEY` | Site key for hCaptcha integration on the front-end. |
| `NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY` | Site key for reCAPTCHA integration on the front-end. |
| `NUXT_API_SECRET` | Shared secret key between Nuxt and Laravel backend. |
| `NUXT_PUBLIC_ROOT_REDIRECT_URL` | Permanently redirects users visiting the root path (/), /integrations, or any non-existent (404) page to the specified URL. This effectively "hides" OpnForm's public pages while keeping forms and admin functionality accessible. See [Subdomain Redirect Configuration](../configuration/subdomain-redirect) for detailed setup and examples. |
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
## Docker Environment Variables
When running OpnForm with Docker, there are some important considerations for handling environment variables:
Docker Compose uses shell variables, a root `.env` file next to
`docker-compose.yml`, or a file passed with `--env-file` when it resolves
`${...}` values in the Compose file. The service-level `env_file` entries
such as `./api/.env` and `./client/.env` only pass variables into the
containers after the Compose file has already been resolved.
### Updating Environment Variables
When changing environment variables in your `.env` files, you need to recreate the containers for the changes to take effect. Simply restarting the containers is not sufficient as Docker only loads environment variables when containers are created.
To apply new environment variable changes:
```bash theme={null}
# For a specific service (e.g., ui)
docker compose down ui
docker compose up -d ui
# For all services
docker compose down
docker compose up -d
```
A simple `docker compose restart` will not reload environment variables from
your `.env` files. You must use `down` and `up` commands to recreate the
containers.
# External File Link Validity
Source: https://docs.opnform.com/configuration/external-file-links
Control how long uploaded-file links remain accessible outside your workspace
File links shared outside a workspace are bearer links: anyone who has the link
can download the file until the link expires. Each workspace admin can choose
how long those links remain valid.
## Configure the validity period
1. Open the workspace settings.
2. Select **Security & data**.
3. Under **External file links**, choose 24 hours, 3 days, 7 days, 14 days, or
30 days, then save.
New workspaces use a 24-hour validity period by default.
## Where the policy applies
The selected period is used for new uploaded-file links included in:
* Form email notifications
* CSV exports, including the download link for asynchronous exports
* Webhooks, Activepieces, n8n, and Zapier
* Slack, Discord, and Telegram notifications
* Google Sheets rows
Existing links keep their original expiration. Internal dashboard file previews
and form assets are not changed by this workspace setting.
## Show uploaded images in notification emails
Each email integration can display uploaded images and signatures directly in
the message:
1. Open the form editor and select **Integrations**.
2. Create or edit an email integration.
3. Enable **Include submission data**.
4. Enable **Display uploaded images inline**.
The setting is disabled by default. GIF, JPEG, and PNG files up to 6 MB each
appear vertically in the email and remain clickable through their secure file
links. Images that are too large or incompatible stay available as readable
file links without blocking the notification. Other uploaded file types also
appear with their filename instead of exposing the raw API URL.
An image embedded in an email remains available in the recipient's mailbox.
The external file link can expire, but the embedded copy cannot be revoked
from a message that has already been sent.
Choose the shortest period that still fits the recipient's workflow. For
example, a team that processes weekend submissions on Monday can select
72 hours so links sent on Friday remain available through Monday.
# OAuth Integration Setup
Source: https://docs.opnform.com/configuration/oauth-setup
Complete guide to configuring OAuth integrations for Google, Stripe, and Telegram in OpnForm
OpnForm supports several OAuth integrations that enable users to connect external services for enhanced functionality. This guide covers setting up Google, Stripe, and Telegram OAuth integrations.
OpnForm handles OAuth redirect URLs automatically. You don't need to manually configure redirect URLs in your OAuth applications - the system generates them dynamically based on your domain configuration.
If you're looking to add a new OAuth integration to OpnForm, check out the complete developer guide with architecture overview and implementation steps.
## Available OAuth Integrations
Authentication, Google One Tap, and Google Sheets integration for form data export
Payment processing integration for collecting payments through forms
Notification integration for form submission alerts via Telegram
## Google OAuth
Google OAuth enables user authentication and integrates with Google Sheets for exporting form submissions.
### Features
* **User Authentication**: Allow users to sign up and log in with their Google accounts
* **Google One Tap**: Seamless authentication with Google's One Tap sign-in widget
* **Google Sheets Integration**: Export form submissions directly to Google Sheets
* **Automatic Scope Management**: OpnForm requests appropriate permissions based on usage
### Setup Steps
Navigate to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project or select an existing one.
Enable the following APIs for your project:
* Google+ API (for authentication)
* Google Drive API (for Sheets integration)
* Google Sheets API (for spreadsheet access)
1. Go to **APIs & Services > OAuth consent screen**
2. Choose **External** user type (unless using Google Workspace)
3. Fill in the required information:
* **App name**: Your application name
* **User support email**: Your contact email
* **Developer contact information**: Your email address
4. Add scopes if needed (OpnForm handles this automatically)
5. Add test users if your app is in testing mode
1. Go to **APIs & Services > Credentials**
2. Click **Create Credentials > OAuth 2.0 Client IDs**
3. Select **Web application** as the application type
4. Add authorized origins:
```
https://yourdomain.com
```
5. Leave redirect URIs empty - OpnForm handles these automatically
For Google One Tap to work, ensure your domain is added to the authorized origins. This enables the seamless authentication widget to function properly.
Save the **Client ID** and **Client Secret** for environment configuration.
Add the following variables to your backend `.env` file:
```bash theme={null}
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_FONTS_API_KEY=your_google_fonts_api_key (optional)
```
The Google Fonts API key is optional and only needed if you want to use Google Fonts in your forms.
## Stripe OAuth
Stripe OAuth enables payment processing capabilities within OpnForm, allowing you to collect payments through your forms.
### Features
* **Payment Collection**: Accept payments directly through forms
* **Multiple Accounts**: Connect multiple Stripe accounts for different forms
* **Automatic Processing**: Handle payment intents and confirmations seamlessly
### Setup Steps
1. Log into your [Stripe Dashboard](https://dashboard.stripe.com/)
2. Navigate to **Settings > Connect > Platform settings**
3. Click **Get started** to enable Stripe Connect
1. Set your **Platform name** and **Support email**
2. Add your website URL in **Brand settings**
3. Configure **Redirect URIs** (OpnForm handles these automatically)
4. Save your settings
1. In **Settings > Connect > Platform settings**
2. Find your **Platform client ID** in the OAuth settings section
3. Note your **Client secret** (you may need to generate one)
Save the **Client ID** and **Client Secret** for environment configuration.
Add the following variables to your backend `.env` file:
```bash theme={null}
STRIPE_CLIENT_ID=your_stripe_client_id
STRIPE_CLIENT_SECRET=your_stripe_client_secret
```
These OAuth credentials are separate from your regular Stripe API keys. The OAuth credentials enable connecting multiple Stripe accounts, while regular API keys are for direct payment processing.
## Telegram OAuth
Telegram OAuth enables notification integration, allowing OpnForm to send form submission alerts to Telegram chats.
### Features
* **Form Notifications**: Receive instant notifications when forms are submitted
* **Rich Messages**: Formatted messages with form data and submission details
* **Multiple Chats**: Connect different Telegram accounts for various forms
**Domain Requirement**: Telegram requires that your bot is linked to a valid domain with HTTPS. Localhost URLs are not supported. For development, consider using a tunneling service like [localtunnel](https://theboroer.github.io/localtunnel-www/) to expose your local development server with a public HTTPS URL.
### Setup Steps
1. Open Telegram and search for [@BotFather](https://t.me/botfather)
2. Start a conversation and send `/newbot`
3. Follow the prompts to name your bot and choose a username
4. Save the **Bot Token** provided by BotFather
Your bot token will look like: `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
1. Send `/setdomain` to [@BotFather](https://t.me/botfather)
2. Select your bot from the list
3. Enter your domain (e.g., `yourdomain.com` or for development: `abc123.loca.lt`)
4. BotFather will confirm the domain has been linked
**For Development**: If you're developing locally, use [localtunnel](https://theboroer.github.io/localtunnel-www/) to get a public HTTPS URL:
```bash theme={null}
npm install -g localtunnel
lt --port 3000 # Replace 3000 with your local port
```
Use the provided `.loca.lt` URL as your domain with BotFather.
Add the following variable to your backend `.env` file:
```bash theme={null}
TELEGRAM_BOT_TOKEN=your_bot_token # format: 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
```
The bot ID is automatically extracted from the bot token (the numeric part before the colon), so you don't need to configure it separately.
1. Start a conversation with your bot in Telegram
2. Connect the bot in OpnForm's integration settings
3. Submit a test form to verify notifications work
You should receive a formatted message with form submission details.
## Security Considerations
**Important Security Notes:**
* Never commit OAuth credentials to version control
* Use environment variables for all sensitive configuration
* Regularly rotate your OAuth secrets and API keys
* Monitor OAuth application usage in your provider dashboards
## Troubleshooting
**Common problems and solutions:**
* **"OAuth app blocked"**: Ensure your OAuth consent screen is properly configured
* **"Invalid redirect URI"**: OpnForm handles redirects automatically - don't manually configure them
* **"Insufficient permissions"**: Check that required APIs are enabled in Google Cloud Console
* **"Quota exceeded"**: Monitor your API usage in Google Cloud Console
**Common problems and solutions:**
* **"Invalid client ID"**: Verify your Stripe Connect platform is properly set up
* **"Webhook failures"**: Ensure your domain is accessible and SSL is configured
* **"Payment processing errors"**: Check your Stripe account status and verification
* **"Connect account creation failed"**: Verify platform settings and business details
**Common problems and solutions:**
* **"Bot not responding"**: Ensure your bot token is correct and the bot is not disabled
* **"Message delivery failed"**: Check that users have started a conversation with your bot
* **"Formatting errors"**: Telegram uses MarkdownV2 formatting for rich messages
* **"Rate limiting"**: Telegram has message rate limits - avoid sending too many messages quickly
## Testing Your Integration
Check that all environment variables are set correctly and restart your application.
Try logging in with each OAuth provider to ensure the authentication flow works.
* **Google**: Create a form and test exporting to Google Sheets
* **Stripe**: Set up a payment form and process a test transaction
* **Telegram**: Configure notifications and submit a test form
Check your application logs for any OAuth-related errors or warnings.
With properly configured OAuth integrations, your OpnForm installation will support seamless user authentication and powerful third-party service integrations.
# OIDC SSO Configuration
Source: https://docs.opnform.com/configuration/oidc-sso
Complete guide to configuring OpenID Connect Single Sign-On for your workspace
**Self-hosted availability**: OIDC SSO is available on self-hosted
installations without an Enterprise license. Free self-hosted instances are
limited to 2 users total across the whole instance, regardless of whether
users are created by registration, invitations, or OIDC provisioning. An
Enterprise license is required to add more users.
OpnForm supports OpenID Connect (OIDC) based Single Sign-On (SSO), allowing users to authenticate using any OIDC-compliant identity provider (IdP).
OpnForm displays the exact callback URL for each connection. Copy that URL
into your IdP's allowed redirect URIs; it must match exactly.
## Features
* **Domain-Based Routing**: Automatically routes users to the correct OIDC provider based on their email domain
* **Group-to-Role Mapping**: Automatically assign workspace roles based on IdP groups
* **Custom Field Mappings**: Support for custom claim field names from your IdP
* **Automatic User Provisioning**: Creates users automatically on first login
* **Force Login Mode**: Optionally require all users to authenticate via OIDC
## Prerequisites
Before setting up OIDC SSO, ensure you have:
1. An OIDC-compliant identity provider (IdP) configured
2. OAuth client credentials (Client ID and Client Secret) from your IdP
3. Admin access to the workspace where you want to configure SSO
4. The issuer URL from your IdP (typically found in the `.well-known/openid-configuration` endpoint)
### Self-hosted instance setup
Before creating an OIDC connection, make sure that OpnForm is configured with
its real public URL. This is a deployment prerequisite: it determines the
callback URL shown in the OIDC connection form.
For the standard Docker deployment where OpnForm is available at
`https://forms.example.com`, the person who manages the deployment must set:
| File | Variables |
| - | - |
| `api/.env` | `APP_URL=https://forms.example.com` `FRONT_URL=https://forms.example.com` |
| `client/.env` | `NUXT_PUBLIC_APP_URL=https://forms.example.com` `NUXT_PUBLIC_API_BASE=https://forms.example.com/api` |
Do not configure the callback URI at your IdP if the value displayed in
OpnForm contains `localhost`. Ask the person who manages the deployment to
set the public instance URL, then recreate the containers before continuing.
If deployment and OIDC configuration are handled by different people, share
this checklist with the person who manages the instance. See [Public
instance URL](./environment-variables#public-instance-url-self-hosted) for
the full reference and [Docker Deployment](../deployment/docker#configure-your-public-instance-url)
for the Docker steps.
On self-hosted Community instances, OIDC can provision users until the
instance reaches the free 2-user limit. Existing linked users can continue
signing in with OIDC. Activate a self-hosted Enterprise license to add more
than 2 users.
## Setup Steps
From your identity provider, collect the following information:
* **Issuer URL**: The base URL of your IdP (e.g., `https://idp.company.com`)
* **Client ID**: OAuth client identifier
* **Client Secret**: OAuth client secret
* **Email Domain**: The domain you want to use for routing (e.g., `company.com`)
The issuer URL is typically found in your IdP's OpenID configuration endpoint:
`{issuer}/.well-known/openid-configuration`
1. Navigate to your workspace settings
2. Go to the **SSO** section
3. Click **Add Connection**
4. Fill in the connection details:
* **Name**: Display name for this connection (e.g., "Company SSO")
* **Slug**: Unique identifier used in URLs (e.g., `company-sso`)
* **Email Domain**: Domain for routing users (e.g., `company.com`)
* **Issuer URL**: Your IdP's issuer URL
* **Client ID**: OAuth client ID from your IdP
* **Client Secret**: OAuth client secret from your IdP
The redirect URI will be displayed automatically. Copy this URL and add it to your IdP's allowed redirect URIs.
1. Copy the redirect URI shown in the connection form
2. In your IdP's application settings, add this redirect URI to the allowed redirect URIs list
3. Save the configuration in your IdP
**Important**: The redirect URI must match exactly. Ensure you copy the full URL including the protocol (https\://) and path.
If your IdP provides group information in the **ID token**, you can map
groups to workspace roles:
1. In the connection settings, expand **Role Mappings**
2. Click **Add Mapping**
3. Enter the exact value from the token's `groups` (or `group`) claim
4. Select the corresponding workspace role (owner, admin, editor, or member)
5. Repeat for each group you want to map
If a user belongs to multiple groups, the highest privilege role will be assigned. Role priority: owner > admin > editor > member.
Matching is exact and case-sensitive. Copy the raw value emitted in the
ID token, not a display name from your IdP's administration interface
unless that display name is itself the claim value. Many IdPs emit an
immutable group identifier rather than a human-readable name.
OpnForm reads only the `groups` or `group` claim. Configure your IdP to
include one of these claims in the **ID token**. A custom claim name or
a claim named `roles` is not used for role mapping.
If your IdP uses non-standard claim names for email or name:
1. In the connection settings, expand **Field Mappings**
2. Enter the custom claim field name for email (if different from `email`)
3. Enter the custom claim field name for name (if different from `name`)
4. Save the connection
If field mappings are not configured, OpnForm will use standard OIDC claim names (`email`, `name`, etc.).
1. Ensure the connection is **Enabled**
2. Save the connection
3. Test the login flow:
* Navigate to the login page
* Enter an email address matching your configured domain
* You should be redirected to your IdP for authentication
* After successful authentication, you'll be redirected back and logged in
If everything is configured correctly, users with emails from your domain will automatically be routed to your IdP for authentication.
## Force Login Mode
Force Login Mode requires all users to authenticate via OIDC, disabling password-based login.
### Configuration
Set the `OIDC_FORCE_LOGIN` environment variable to `true` in your backend `.env` file:
```bash theme={null}
OIDC_FORCE_LOGIN=true
```
**Important**: Ensure at least one OIDC connection is configured and enabled
before enabling `force_login`. If no connections exist, password login will
remain available as a fallback.
### Behavior
When enabled:
* Password-based login attempts are blocked
* Users attempting password login receive: "Password-based login is disabled. Please use OIDC authentication."
* The login form automatically redirects users to their OIDC provider based on email domain
* If `force_login` is enabled but no OIDC connections exist, password login remains available as a fallback
* A user who is securely linking a pre-existing OpnForm account may sign in with that account's password once. The temporary link request is bound to the same email address and expires after 15 minutes.
### Use Cases
* Organizations requiring all users to authenticate via corporate SSO
* Security policies mandating centralized identity management
* Compliance requirements for authentication methods
For more details, see the [Environment Variables](../configuration/environment-variables#oidc_force_login) documentation.
## Updating a connection or changing the instance URL
You can edit an existing OIDC connection without deleting it.
* Leave **Client Secret** empty to retain the current secret. Enter a value only
when rotating or replacing it.
* After changing the public URL of a self-hosted instance, update both the
backend `APP_URL` and `FRONT_URL`, plus the frontend `NUXT_PUBLIC_APP_URL`
and `NUXT_PUBLIC_API_BASE`. Recreate the relevant containers so the new
environment values are loaded.
* Edit the connection, click **Use current app URL** below its redirect URI,
then save. Copy the refreshed URL into your IdP's allowed redirect URIs.
See [Custom Domain](../configuration/custom-domain) and [Environment Variables](../configuration/environment-variables#updating-environment-variables) for the instance URL and container steps.
## Troubleshooting
**Possible causes:**
* Email domain doesn't match the configured domain
* Connection is disabled
* Domain not properly configured in connection settings
**Solution**: Verify the domain matches exactly (case-insensitive) and the connection is enabled.
**Possible causes:**
* Redirect URI not added to IdP's allowed list
* Redirect URI copied incorrectly
* HTTPS/HTTP mismatch
**Solution**: Copy the exact redirect URI from the connection settings and ensure it's added to your IdP's allowed redirect URIs. Verify HTTPS is used in production. If the instance URL changed, edit the connection, click **Use current app URL**, save it, and update the URL at the IdP.
**Possible causes:**
* Groups not included in ID token
* The mapping value does not exactly match the value in the token
* The IdP sent a display name where OpnForm expects an identifier, or vice versa
* The group claim is not configured in the ID token
* The IdP omitted groups because the user belongs to too many groups
**Solution**: Inspect the decoded **ID token** returned for the user. Confirm it contains a `groups` or `group` claim, then copy one of its raw values exactly (including case) into the mapping. Configure the IdP to emit only the groups needed by this application if it omits a large group list.
OAuth authorization codes can only be exchanged once. This commonly happens
when the callback URL is refreshed, revisited from history, or opened in a
second tab.
**Solution**: OpnForm starts one fresh sign-in automatically. If that
cannot be completed, select **Back to sign in** and try again. Do not
refresh or reuse the callback URL. Clearing the browser cache is not
necessary.
**Possible causes:**
* Email claim missing from ID token
* Field mappings incorrect
* Account with same email already exists (account linking blocked for security)
**Solution**: Verify email is present in ID token. Check field mappings if using custom claim names. For existing accounts, contact administrator to link accounts.
**Possible causes:**
* `OIDC_FORCE_LOGIN` not set to `true`
* No OIDC connections configured
* Environment variable not loaded (restart required)
**Solution**: Verify environment variable is set correctly. Ensure at least one OIDC connection exists and is enabled. Restart application after changing environment variables.
## Security Considerations
### HTTPS Requirement
In production, all OIDC redirects require HTTPS. Ensure your application is served over HTTPS before configuring OIDC.
### State Validation
OpnForm generates and validates an OIDC `state` value by default for login redirects. Callback requests without a valid, unexpired state are rejected to prevent login CSRF.
State validation can be disabled explicitly on an OIDC connection by setting `options.require_state` to `false`, but this is not recommended unless required for a legacy identity provider. Keep it enabled for Microsoft Entra ID and other standard OIDC providers.
### Account Linking
For security, OpnForm prevents automatic linking of existing accounts. If a user with the same email already exists but is not linked to the OIDC connection, OpnForm asks that user to sign in once with their existing OpnForm password. It then links the SSO identity only when the temporary link request and authenticated account have the same email address. The link request expires after 15 minutes.
### SSO-Only Accounts
Users created via OIDC SSO are created with:
* Random 64-character password (prevents password-based login)
* Email automatically verified
* Provider metadata stored for tracking
These accounts can only authenticate via OIDC, not with passwords.
## Best Practices
* **Test First**: Always test with a small group before rolling out to all users
* **Monitor Logs**: Check application logs for OIDC-related errors during initial setup
* **Backup Access**: Ensure at least one admin account can still access the system if OIDC fails
* **Regular Updates**: Keep your IdP configuration in sync with OpnForm connection settings
* **Documentation**: Document your IdP configuration for future reference
With properly configured OIDC SSO, your users can seamlessly authenticate
using their organization's identity provider, improving security and user
experience.
# Subdomain Redirect
Source: https://docs.opnform.com/configuration/subdomain-redirect
Hide OpnForm's public pages by redirecting users to your main website
The `NUXT_PUBLIC_ROOT_REDIRECT_URL` environment variable allows you to "hide" OpnForm's public pages (landing page, integrations page, etc.) by redirecting users to your main website or any other destination when they access certain routes.
## Use Case
This feature is particularly useful when you're running OpnForm on a subdomain (e.g., `forms.yourdomain.com`) and want to:
* Hide the default OpnForm landing page and branding
* Prevent users from discovering your form creation interface
* Redirect visitors to your main website instead
* Keep only the essential form and admin functionality accessible
## How It Works
When `NUXT_PUBLIC_ROOT_REDIRECT_URL` is set, OpnForm will permanently redirect (301) users visiting these routes:
* **Root path** (`/`) - The landing page
* **Integrations page** (`/integrations`) - The integrations showcase
* **Any non-existent pages** (404 errors) - Undefined routes
### What Still Works
The following routes remain fully accessible and will **NOT** be redirected:
* **Form URLs**: `forms.yourdomain.com/forms/my-form` ✅
* **Admin login**: `forms.yourdomain.com/login` ✅
* **Admin dashboard**: `forms.yourdomain.com/home` ✅
* **All admin pages**: Settings, workspaces, templates, etc. ✅
## Configuration
### Environment Variable Setup
Add this to your **client** `.env` file (located in the `client` directory):
```bash theme={null}
NUXT_PUBLIC_ROOT_REDIRECT_URL=https://yourdomain.com
```
### Example Scenarios
**Scenario 1: Redirect to main website**
```bash theme={null}
NUXT_PUBLIC_ROOT_REDIRECT_URL=https://mycompany.com
```
* Visiting `forms.mycompany.com` → redirects to `https://mycompany.com`
* Visiting `forms.mycompany.com/forms/contact` → works normally ✅
**Scenario 2: Redirect to specific landing page**
```bash theme={null}
NUXT_PUBLIC_ROOT_REDIRECT_URL=https://mycompany.com/contact
```
* Visiting `forms.mycompany.com` → redirects to `https://mycompany.com/contact`
**Scenario 3: Redirect to documentation**
```bash theme={null}
NUXT_PUBLIC_ROOT_REDIRECT_URL=https://docs.mycompany.com
```
## User Experience
### For Form Respondents
* Can access shared forms normally via direct links
* If they accidentally visit the root domain, they're redirected to your main site
* No exposure to OpnForm branding or interface
### For Administrators
* Can still access the admin panel via `/login`
* All admin functionality remains fully accessible
* Forms can be managed and created as usual
## Implementation Notes
* The redirect is a **permanent redirect** (HTTP 301)
* The redirect happens on the client-side (Nuxt)
* You must recreate your Docker containers after changing this environment variable
* The URL should include the protocol (`https://` or `http://`)
## Updating the Configuration
If you're using Docker, remember to recreate your containers after changing this environment variable:
```bash theme={null}
docker compose down ui
docker compose up -d ui
```
A simple `docker compose restart` will not reload the new environment
variable. You must use `down` and `up` to recreate the container.
# Authentication architecture
Source: https://docs.opnform.com/contributing/authentication-architecture
Understand when OpnForm uses JWT, Sanctum personal access tokens, and Passport OAuth 2.1.
OpnForm intentionally uses three authentication mechanisms. They serve different clients and should not be substituted for one another without a dedicated migration.
| Mechanism | Client | Purpose |
| - | - | - |
| JWT | First-party Nuxt application | Existing browser login and authenticated application requests |
| Sanctum personal access tokens | API users and integrations | Long-lived tokens created by a user, constrained by token abilities and the `auth.multi` route policy |
| Passport OAuth 2.1 | Third-party delegated clients | Authorization Code with PKCE, consent, scoped access, and refresh tokens |
Do not migrate first-party login to Passport. Passport is for delegated access by external clients. A future first-party authentication migration should evaluate Sanctum's stateful cookie authentication as a separate project.
## Delegated OAuth flow
The `OAuthServiceProvider` owns Passport configuration, routes, token lifetimes, consent rendering, and scope descriptions. MCP adds its own dynamic client registration policy, login bridge, metadata, and `mcp:use` scope requirement on top of that generic layer.
1. The client dynamically registers an allowed redirect URI for the MCP integration.
2. The authorization request must use Authorization Code with PKCE and the `S256` challenge method.
3. OpnForm authenticates the account through the existing first-party login flow.
4. The generic consent screen displays the callback destination and every requested scope description.
5. Passport issues a scoped access token and refresh token.
6. The MCP endpoint accepts the token only when it includes `mcp:use` and the account is not blocked.
Guest MCP requests remain available without a bearer token. A bearer token that is invalid or missing the required scope is rejected rather than treated as a guest request.
## Configuration
```dotenv theme={null}
# Optional override. The default follows Cloud or self-hosted MCP availability.
OAUTH_ENABLED=true
# Seven days, in minutes.
OAUTH_ACCESS_TOKEN_TTL=10080
# Refresh tokens remain valid for 30 days.
OAUTH_REFRESH_TOKEN_TTL_DAYS=30
```
These values are independent of `JWT_TTL`. Changing a delegated OAuth token lifetime must not change first-party sessions.
## Adding another delegated client
When you add another OAuth consumer:
1. Add a narrowly defined scope and a user-readable description to `config/oauth.php`.
2. Keep redirect URI validation specific to the client registration endpoint.
3. Require PKCE `S256` for public clients.
4. Enforce the scope at the protected resource boundary.
5. Add consent, token, insufficient-scope, account-blocking, and revocation tests.
Do not add product-specific permissions directly to the generic OAuth consent template. The template renders the scopes requested and validated by Passport.
# Getting Started
Source: https://docs.opnform.com/contributing/getting-started
Learn how to contribute to OpnForm
## Development Setup
### Docker Setup (Recommended)
The easiest way to get started with OpnForm development is to use our Docker-based development environment. It provides:
* Hot-reload for both frontend and backend
* All necessary services pre-configured
* Consistent development environment across all platforms
Follow our [Docker Development Setup](/deployment/docker-development) guide to get started.
### Manual Setup
If you prefer not to use Docker or need a custom setup, you can follow our [Local Deployment](/deployment/local-deployment#manual-setup) guide for manual installation instructions.
## Contributing Guidelines
Welcome to the OpnForm contributing guide! Here are some helpful links to get you started:
Learn how to configure the environment variables for OpnForm.
Follow the steps to add a new form block to your project.
Discover how to integrate new services with OpnForm.
We sometimes offer bounties for certain issues. Check out our [bounties page](https://console.algora.io/org/OpnForm) for more information.
# Creating a New Form Block
Source: https://docs.opnform.com/contributing/new-form-block
Learn how to create a new form block for OpnForm
Whoops not ready yet! This page is still to be written.
# Adding a New OAuth Integration
Source: https://docs.opnform.com/contributing/new-integration
Complete guide to implementing OAuth integrations in OpnForm with architecture overview, flow diagrams, and step-by-step implementation
This guide covers how to add new OAuth integrations to OpnForm. The system uses a modern, service-oriented architecture with intent-based OAuth flows to handle different authentication scenarios including user authentication, account integrations, and widget-based providers.
Before implementing a new integration, you may want to review the OAuth
configuration guide to understand how to set up the existing Google, Stripe,
and Telegram integrations.
## System Architecture Overview
OpnForm's OAuth system is built around several key architectural patterns:
* **Intent-based Flows**: Different OAuth scopes and handling based on auth vs integration intents
* **Orchestrator Pattern**: Central `OAuthFlowOrchestrator` coordinates all OAuth flows
* **Service-Oriented Architecture**: Specialized services handle context, user data, invites, and provider management
* **State-based Context Management**: Secure state tokens replace session-based context storage
* **Driver Pattern**: Abstracted OAuth provider implementations with support for redirect and widget flows
* **Email Restrictions**: Capability-based email restrictions for workspace invitations
* **Message-based Communication**: Cross-window communication for OAuth callbacks
```mermaid theme={null}
graph TB
subgraph "Frontend (Nuxt/Vue)"
UI[User Interface]
useOAuth[useOAuth Composable]
useWindowMessage[useWindowMessage Composable]
useAuthFlow[useAuthFlow Composable]
TanStackQuery[TanStack Query Cache]
end
subgraph "Backend API (Laravel)"
OAuthController[OAuthController Simplified Controller]
OAuthFlowOrchestrator[OAuthFlowOrchestrator Main Orchestrator]
subgraph "OAuth Services"
OAuthContextService[OAuthContextService State Management]
OAuthUserDataService[OAuthUserDataService Data Extraction]
OAuthInviteService[OAuthInviteService Invite Validation]
OAuthUserService[OAuthUserService User Management]
OAuthProviderService[OAuthProviderService Provider Management]
end
subgraph "Supporting Services"
WorkspaceInviteService[WorkspaceInviteService Workspace Creation]
end
subgraph "OAuth Drivers"
GoogleDriver[OAuthGoogleDriver + Email Restrictions]
GoogleOneTapDriver[OAuthGoogleOneTapDriver]
StripeDriver[OAuthStripeDriver]
TelegramDriver[OAuthTelegramDriver]
NewDriver[OAuthNewProviderDriver]
end
subgraph "Data Layer"
UserModel[User Model]
OAuthProviderModel[OAuthProvider Model]
Cache[Laravel Cache State Storage]
end
end
subgraph "External Services"
GoogleOAuth[Google OAuth]
StripeOAuth[Stripe Connect]
TelegramWidget[Telegram Widget]
NewProviderOAuth[New Provider OAuth]
end
%% Frontend to Backend API calls
UI --> useOAuth
useOAuth --> OAuthController
useAuthFlow --> OAuthController
%% Controller to Orchestrator
OAuthController --> OAuthFlowOrchestrator
%% Orchestrator coordination
OAuthFlowOrchestrator --> OAuthContextService
OAuthFlowOrchestrator --> OAuthUserDataService
OAuthFlowOrchestrator --> OAuthInviteService
OAuthFlowOrchestrator --> OAuthUserService
OAuthFlowOrchestrator --> OAuthProviderService
%% Service dependencies
OAuthUserService --> WorkspaceInviteService
OAuthInviteService --> GoogleDriver
OAuthUserDataService --> GoogleDriver
OAuthUserDataService --> GoogleOneTapDriver
OAuthUserDataService --> StripeDriver
OAuthUserDataService --> TelegramDriver
%% Drivers to External Services
GoogleDriver --> GoogleOAuth
GoogleOneTapDriver --> GoogleOAuth
StripeDriver --> StripeOAuth
TelegramDriver --> TelegramWidget
NewDriver --> NewProviderOAuth
%% Data persistence
OAuthUserService --> UserModel
OAuthProviderService --> OAuthProviderModel
OAuthContextService --> Cache
WorkspaceInviteService --> UserModel
%% Cross-window communication
useWindowMessage -.->|"OAuth Callbacks"| useOAuth
useWindowMessage -.->|"Cache Invalidation"| TanStackQuery
%% Cache invalidation
TanStackQuery -.->|"Refetch on Changes"| useOAuth
classDef frontend fill:#e1f5fe
classDef backend fill:#f3e5f5
classDef external fill:#fff3e0
classDef data fill:#e8f5e8
classDef orchestrator fill:#e8f4f8
class UI,useOAuth,useWindowMessage,useAuthFlow,TanStackQuery frontend
class OAuthController,OAuthContextService,OAuthUserDataService,OAuthInviteService,OAuthUserService,OAuthProviderService,WorkspaceInviteService,GoogleDriver,GoogleOneTapDriver,StripeDriver,TelegramDriver,NewDriver backend
class OAuthFlowOrchestrator orchestrator
class GoogleOAuth,StripeOAuth,TelegramWidget,NewProviderOAuth external
class UserModel,OAuthProviderModel,Cache data
```
## Core Components
### 1. OAuthController (`api/app/Http/Controllers/Auth/OAuthController.php`)
The simplified controller that delegates all OAuth logic to the orchestrator:
```php theme={null}
class OAuthController extends Controller
{
public function __construct(
private OAuthFlowOrchestrator $flowOrchestrator
) {}
// Initiates OAuth flow using validated request
public function redirect(OAuthRedirectRequest $request, string $provider)
{
return response()->json(
$this->flowOrchestrator->processRedirect(
$provider,
$request->validated()
)
);
}
// Handles standard OAuth callbacks
public function callback(Request $request, string $provider)
{
$params = $request->all();
$result = $this->flowOrchestrator->processCallback($provider, $params);
return response()->json($result);
}
// Handles widget-based OAuth
public function handleWidgetCallback(Request $request, string $service)
{
$result = $this->flowOrchestrator->processWidgetCallback($service, $request);
return response()->json($result);
}
}
```
**Key Features:**
* **Simplified Architecture**: All logic delegated to `OAuthFlowOrchestrator`
* **Request Validation**: Uses `OAuthRedirectRequest` for structured validation
* **Consistent Responses**: Standardized JSON response format
### 2. OAuthFlowOrchestrator (`api/app/Service/OAuth/OAuthFlowOrchestrator.php`)
The main orchestrator service that coordinates all OAuth flows:
```php theme={null}
class OAuthFlowOrchestrator
{
public const INTENT_AUTH = 'auth';
public const INTENT_INTEGRATION = 'integration';
public function __construct(
private OAuthContextService $contextService,
private OAuthInviteService $inviteService,
private OAuthUserDataService $userDataService,
private OAuthUserService $oauthUserService,
private OAuthProviderService $oauthProviderService
) {}
// Process OAuth redirect with state management
public function processRedirect(string $provider, array $params): array
// Process OAuth callback with context restoration
public function processCallback(string $provider, array $params): array
// Process widget-based OAuth
public function processWidgetCallback(string $service, Request $request): array
}
```
**Key Features:**
* **Centralized Coordination**: Single entry point for all OAuth flows
* **State Management**: Handles OAuth state tokens for security
* **Invite Integration**: Supports workspace invitations with email restrictions
* **Intent-based Routing**: Routes to appropriate flow based on auth vs integration intent
### 3. OAuthContextService (`api/app/Service/OAuth/OAuthContextService.php`)
Service that manages OAuth context using secure state tokens:
```php theme={null}
class OAuthContextService
{
// Store context with unique state token
public function storeContext(array $context): string
// Retrieve context using state token
public function getContext(?string $stateToken = null): ?array
// Clear context after use
public function clearContext(?string $stateToken = null): void
// Get specific context values
public function getIntent(): string
public function getInvitedEmail(): ?string
public function getInviteToken(): ?string
}
```
**Key Features:**
* **State Token Security**: Uses cryptographically secure state tokens instead of session storage
* **Context Isolation**: Each OAuth flow has isolated context
* **Automatic Cleanup**: Context automatically expires and clears after use
### 4. OAuthUserDataService (`api/app/Service/OAuth/OAuthUserDataService.php`)
Service that extracts and normalizes user data from OAuth providers:
```php theme={null}
class OAuthUserDataService
{
// Extract user data from standard OAuth redirect
public function extractFromRedirect(OAuthProviderService $providerService): array
// Extract user data from widget-based authentication
public function extractFromWidget(OAuthProviderService $providerService, Request $request): array
}
```
**Key Features:**
* **Data Normalization**: Standardizes user data across different OAuth providers
* **Widget Support**: Handles both redirect and widget-based authentication flows
* **Error Handling**: Validates and verifies OAuth provider responses
### 5. OAuthInviteService (`api/app/Service/OAuth/OAuthInviteService.php`)
Service that handles workspace invitations and email restrictions:
```php theme={null}
class OAuthInviteService
{
// Validate invite token
public function validateInviteToken(string $inviteToken): UserInvite
// Get invited email from token
public function getInvitedEmail(string $inviteToken): string
// Configure driver with email restrictions
public function configureDriverEmailRestrictions(OAuthDriver $driver, ?string $invitedEmail = null): void
// Validate email matches invitation
public function validateEmailRestrictions(string $email, ?string $invitedEmail = null): void
}
```
**Key Features:**
* **Invite Validation**: Validates workspace invitation tokens
* **Email Restrictions**: Enforces email restrictions for OAuth providers that support it
* **Capability-based**: Uses interfaces to determine if driver supports email restrictions
### 6. OAuthUserService (`api/app/Service/OAuth/OAuthUserService.php`)
Service that handles user-related OAuth operations:
```php theme={null}
class OAuthUserService
{
// Find or create user from OAuth data
public function findOrCreateUser(array $userData, OAuthProviderService $providerService, ?string $inviteToken = null): User
}
```
**Key Features:**
* **User Management**: Creates new users or finds existing ones
* **Workspace Integration**: Uses `WorkspaceInviteService` for workspace assignment
* **UTM Tracking**: Preserves UTM data for analytics
### 7. OAuthProviderService (`api/app/Service/OAuth/OAuthProviderService.php`)
Service that handles OAuth provider record management:
```php theme={null}
class OAuthProviderService
{
// Create or update OAuth provider record
public function createOrUpdateProvider(User $user, OAuthProviderService $providerService, array $userData): OAuthProvider
}
```
**Key Features:**
* **Provider Records**: Manages OAuth provider connections in database
* **Token Management**: Stores and updates OAuth access/refresh tokens
* **Scope Tracking**: Records granted OAuth scopes
### 8. WorkspaceInviteService (`api/app/Service/WorkspaceInviteService.php`)
Service extracted from `RegisterController` that handles workspace creation and invitation acceptance:
```php theme={null}
class WorkspaceInviteService
{
// Get workspace and role based on invitation
public function getWorkspaceAndRole(array $data): array
}
```
**Key Features:**
* **Workspace Creation**: Creates new workspaces for users without invitations
* **Invite Processing**: Handles workspace invitation acceptance with atomic updates
* **Role Assignment**: Assigns appropriate roles based on invitation or default admin role
### 9. OAuth Drivers
Each provider implements the `OAuthDriver` interface with updated methods:
```php theme={null}
interface OAuthDriver
{
public function getRedirectUrl(): string;
public function setRedirectUrl(string $url): self;
public function setScopes(array $scopes): self;
public function setState(string $state): self; // New: Required for state token security
public function getUser(): User;
public function canCreateUser(): bool;
public function getScopesForIntent(string $intent): array;
}
```
For widget-based providers, implement `WidgetOAuthDriver`:
```php theme={null}
interface WidgetOAuthDriver extends OAuthDriver
{
public function verifyWidgetData(array $data): bool;
public function getUserFromWidgetData(array $data): array;
public function isWidgetBased(): bool;
}
```
### 10. Email Restrictions
For providers that support email restrictions (like Google), implement `SupportsEmailRestrictions`:
```php theme={null}
interface SupportsEmailRestrictions
{
/**
* Set email restrictions/hints for the OAuth flow
*/
public function setEmailRestrictions(array $emails): self;
/**
* Check if this driver supports email restrictions
*/
public function supportsEmailRestrictions(): bool;
}
```
The system includes a trait to simplify implementation:
```php theme={null}
use App\Integrations\OAuth\Drivers\Traits\HasEmailRestrictions;
class OAuthGoogleDriver implements OAuthDriver, SupportsEmailRestrictions
{
use HasEmailRestrictions;
// The trait provides:
// - setEmailRestrictions(array $emails): self
// - supportsEmailRestrictions(): bool
// - getPrimaryEmail(): ?string
// - isEmailAllowed(string $email): bool
// - getEmailRestrictionParameters(): array
}
```
**Email Restrictions Features:**
* **Invite Integration**: Automatically configures email hints when OAuth is used for workspace invitations
* **Login Hints**: Providers like Google receive `login_hint` parameter to pre-fill login form
* **Email Validation**: Validates OAuth email matches invited email during authentication
* **Capability-based**: Only applied to drivers that implement `SupportsEmailRestrictions`
## OAuth Flow Types
### Authentication Flow (User Login/Registration)
```mermaid theme={null}
flowchart TD
A["User clicks 'Sign in with Provider'"] --> B["Frontend: useOAuth.guestConnect()"]
B --> C["API: POST /oauth/connect/provider?intent=auth&invite_token=xyz"]
C --> D["OAuthFlowOrchestrator.processRedirect()"]
D --> E["Validate intent & invite token"]
E --> F["Generate secure state token"]
F --> G["Store context in cache with state"]
G --> H["Configure driver with scopes & email restrictions"]
H --> I["Generate OAuth redirect URL with state"]
I --> J["Open OAuth provider window"]
J --> K["User authorizes at provider"]
K --> L["Provider redirects to callback with state"]
L --> M["API: GET /oauth/provider/callback?state=xyz&code=abc"]
M --> N["OAuthFlowOrchestrator.processCallback()"]
N --> O["Retrieve context using state token"]
O --> P["Clear context after retrieval"]
P --> Q["Extract user data from provider"]
Q --> R["Validate email matches invite (if present)"]
R --> S["Find or create user"]
S --> T["Handle workspace invitation"]
T --> U["Create OAuth provider record"]
U --> V["Generate JWT token"]
V --> W["Return token response"]
W --> X["Frontend: Store token"]
X --> Y["Send LOGIN_COMPLETE message"]
Y --> Z["Close OAuth window"]
Z --> AA["Redirect to dashboard"]
E --> BB{Invalid invite?}
BB -->|Yes| CC["Return error"]
O --> DD{Context expired?}
DD -->|Yes| EE["Return 419 error"]
R --> FF{Email mismatch?}
FF -->|Yes| GG["Return email error"]
subgraph "Window Communication"
HH["OAuth Callback Window"] --> II["useWindowMessage.send()"]
II --> JJ["Parent Window Listener"]
JJ --> KK["Cache invalidation"]
KK --> LL["UI updates"]
end
classDef process fill:#e3f2fd
classDef decision fill:#fff3e0
classDef error fill:#ffebee
classDef communication fill:#f3e5f5
classDef state fill:#e8f4f8
class A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA process
class BB,DD,FF decision
class CC,EE,GG error
class HH,II,JJ,KK,LL communication
class F,G,O,P state
```
```mermaid theme={null}
sequenceDiagram
participant U as User
participant F as Frontend
participant API as OAuthController
participant Orch as OAuthFlowOrchestrator
participant Ctx as OAuthContextService
participant P as OAuth Provider
participant Inv as OAuthInviteService
participant US as OAuthUserService
U->>F: Click "Sign in with Provider"
F->>API: POST /oauth/connect/{provider}?intent=auth&invite_token=xyz
API->>Orch: processRedirect(provider, params)
Orch->>Inv: validateInviteToken(xyz) & getInvitedEmail()
Inv-->>Orch: email@domain.com
Orch->>Ctx: storeContext({intent, invite_token, invited_email})
Ctx-->>Orch: stateToken: abc123
Orch->>Orch: Configure driver with email restrictions
Orch-->>API: {url: oauth_url, state: abc123}
API->>P: Redirect user to OAuth provider
P->>U: User authorizes app
P->>API: Callback with state=abc123&code=def456
API->>Orch: processCallback(provider, params)
Orch->>Ctx: getContext(abc123)
Ctx-->>Orch: {intent: auth, invited_email: email@domain.com}
Orch->>Ctx: clearContext(abc123)
Orch->>Orch: Extract user data from provider
Orch->>Inv: validateEmailRestrictions(user_email, invited_email)
Orch->>US: findOrCreateUser(userData, provider, invite_token)
US->>US: Create user & handle workspace invitation
US-->>Orch: User object with JWT
Orch-->>API: {token, user_data}
API->>F: Return JWT token
F->>F: Store token, redirect to dashboard
```
**Key Features of New Flow:**
1. **State Token Security**: Each OAuth flow gets a unique, cryptographically secure state token
2. **Context Isolation**: Context is stored with state token, preventing cross-request interference
3. **Invite Integration**: Workspace invitations are validated and email restrictions applied automatically
4. **Automatic Cleanup**: Context is cleared after successful use to prevent replay attacks
5. **Email Validation**: For invites, OAuth email must match the invited email address
6. **Error Handling**: Comprehensive error handling for expired contexts, invalid invites, email mismatches
### Integration Flow (Connect Account)
```mermaid theme={null}
sequenceDiagram
participant U as Logged User
participant F as Frontend
participant API as OAuthController
participant Orch as OAuthFlowOrchestrator
participant Ctx as OAuthContextService
participant P as OAuth Provider
participant PS as OAuthProviderService
U->>F: Click "Connect Account"
F->>API: POST /oauth/connect/{provider}?intent=integration&autoClose=true
API->>Orch: processRedirect(provider, params)
Orch->>Ctx: storeContext({intent: integration, autoClose: true, intention: 'settings'})
Ctx-->>Orch: stateToken: xyz456
Orch->>Orch: Configure driver with integration scopes
Orch-->>API: {url: oauth_url, state: xyz456}
API->>P: Redirect with full permission scopes
P->>U: User authorizes extended permissions
P->>API: Callback with state=xyz456&code=def789
API->>Orch: processCallback(provider, params)
Orch->>Ctx: getContext(xyz456)
Ctx-->>Orch: {intent: integration, autoClose: true, intention: 'settings'}
Orch->>Ctx: clearContext(xyz456)
Orch->>Orch: Extract user data from provider
Orch->>PS: createOrUpdateProvider(user, provider, userData)
PS-->>Orch: OAuthProvider record
Orch-->>API: {provider: resource, autoClose: true, intention: 'settings'}
API->>F: Return provider details + context
F->>F: Update UI, invalidate cache
```
**Key Features:**
* **Enhanced scopes**: Automatically requests full permissions needed for integrations based on `getScopesForIntent()`
* **Context preservation**: Stores `intention`, `autoClose`, and other settings using secure state tokens
* **Provider management**: Creates/updates `OAuthProvider` records with full OAuth data
* **State security**: Uses same state token security as authentication flows
### Widget Flow (e.g., Telegram)
```mermaid theme={null}
sequenceDiagram
participant U as User
participant W as Widget
participant F as Frontend
participant API as OAuthController
participant Orch as OAuthFlowOrchestrator
participant Inv as OAuthInviteService
participant UD as OAuthUserDataService
participant US as OAuthUserService
U->>W: Authenticate with provider widget
W->>F: Widget callback with auth data
F->>API: POST /oauth/widget-callback/{provider}?intent=auth&invite_token=xyz
API->>Orch: processWidgetCallback(provider, request)
Orch->>Inv: validateInviteToken(xyz) & getInvitedEmail() [if auth intent]
Orch->>UD: extractFromWidget(provider, request)
UD->>UD: Verify widget data signature
UD->>UD: Extract and normalize user data
UD-->>Orch: userData
Orch->>Inv: validateEmailRestrictions(user_email, invited_email) [if auth intent]
Orch->>US: findOrCreateUser(userData, provider, invite_token) [if auth intent]
US->>US: Create user & handle workspace invitation
US-->>Orch: User object with JWT
Orch-->>API: {token, user_data} or {provider, autoClose, intention}
API->>F: Return auth token or provider details
F->>F: Update UI, show success
```
**Widget Authentication Features:**
* **No redirect flow**: Authentication happens in-place via widget, no OAuth redirect required
* **Data verification**: Cryptographic verification of widget data using provider-specific methods
* **Direct provider creation**: Immediately creates provider records without state tokens (no callback URL)
* **Invite Integration**: Supports workspace invitations with same email validation as redirect flows
* **Orchestrated Processing**: Uses same `OAuthFlowOrchestrator` as redirect flows for consistency
## Frontend Integration
### Window Message Communication
The frontend uses `useWindowMessage` composable for cross-window communication:
```javascript theme={null}
// WindowMessageTypes for consistent messaging
export const WindowMessageTypes = {
LOGIN_COMPLETE: "login-complete",
AFTER_LOGIN: "after-login",
OAUTH_PROVIDER_CONNECTED: "oauth-provider-connected",
};
// Usage in OAuth callback
const { send } = useWindowMessage(WindowMessageTypes.LOGIN_COMPLETE);
send(window.opener, {
eventType: WindowMessageTypes.LOGIN_COMPLETE,
useMessageChannel: false,
waitForAcknowledgment: false,
});
```
### OAuth Composable (`useOAuth`)
Centralized OAuth operations with TanStack Query integration:
```javascript theme={null}
const { connect, connectMutation, services } = useOAuth();
// Connect to OAuth provider
await connect("google", false, true, true); // service, redirect, newtab, autoClose
// Service definitions with auth types
const services = [
{
name: "google",
title: "Google",
auth_type: "redirect", // Standard OAuth redirect flow
},
{
name: "telegram",
title: "Telegram",
auth_type: "widget", // Widget-based authentication
widget_file: "TelegramWidget",
},
];
```
### Cache Management
The system automatically invalidates TanStack Query cache when OAuth connections change:
```javascript theme={null}
// Listen for OAuth provider connections
windowMessage.listen(
(_event) => {
invalidateProviders(); // Refresh OAuth providers list
},
{
useMessageChannel: false,
acknowledge: false,
}
);
```
## Adding a New Integration
Follow these steps to add a new OAuth provider to OpnForm:
Implement the OAuth driver class with the updated interface:
```php theme={null}
// api/app/Integrations/OAuth/Drivers/OAuthNewProviderDriver.php
provider = Socialite::driver('newprovider');
}
public function getRedirectUrl(): string
{
$parameters = [];
// Add state parameter for security
if ($this->state) {
$parameters['state'] = $this->state;
}
// Add email restriction parameters (if using HasEmailRestrictions trait)
if (method_exists($this, 'getEmailRestrictionParameters')) {
$parameters = array_merge($parameters, $this->getEmailRestrictionParameters());
}
return $this->provider
->scopes($this->scopes ?? [])
->stateless()
->redirectUrl($this->redirectUrl ?? config('services.newprovider.redirect'))
->with($parameters)
->redirect()
->getTargetUrl();
}
public function getUser(): User
{
return $this->provider
->stateless()
->redirectUrl($this->redirectUrl ?? config('services.newprovider.redirect'))
->user();
}
public function canCreateUser(): bool
{
return true; // Set false if provider can't be used for user registration
}
public function setRedirectUrl(string $url): self
{
$this->redirectUrl = $url;
return $this;
}
public function setScopes(array $scopes): self
{
$this->scopes = $scopes;
return $this;
}
public function setState(string $state): self
{
$this->state = $state;
return $this;
}
public function getScopesForIntent(string $intent): array
{
return match ($intent) {
OAuthFlowOrchestrator::INTENT_AUTH => ['user:email', 'read:user'],
OAuthFlowOrchestrator::INTENT_INTEGRATION => ['user:email', 'read:user', 'write:data'],
default => ['user:email', 'read:user'],
};
}
}
```
**New Required Methods**: All drivers must now implement `setState()` for
security. Use `SupportsEmailRestrictions` interface and
`HasEmailRestrictions` trait only if your provider supports email
hints/restrictions (like Google's `login_hint` parameter).
The `setState()` method is required for state token security. The orchestrator will automatically call this method with a secure state token before generating the redirect URL.
Add your new provider to the enum:
```php theme={null}
// api/app/Integrations/OAuth/OAuthProviderService.php
enum OAuthProviderService: string
{
case Google = 'google';
case GoogleOneTap = 'google_one_tap';
case Stripe = 'stripe';
case Telegram = 'telegram';
case NewProvider = 'newprovider'; // Add your provider
public function getDriver(): OAuthDriver
{
return match ($this) {
self::Google => new OAuthGoogleDriver(),
self::GoogleOneTap => new OAuthGoogleOneTapDriver(),
self::Stripe => new OAuthStripeDriver(),
self::Telegram => new OAuthTelegramDriver(),
self::NewProvider => new OAuthNewProviderDriver(), // Add mapping
};
}
public function getDatabaseProvider(): string
{
return match ($this) {
self::Google => 'google',
self::GoogleOneTap => 'google', // Normalize to 'google'
self::Stripe => 'stripe',
self::Telegram => 'telegram',
self::NewProvider => 'newprovider',
};
}
}
```
Add configuration to `api/config/services.php`:
```php theme={null}
// api/config/services.php
'newprovider' => [
'client_id' => env('NEWPROVIDER_CLIENT_ID'),
'client_secret' => env('NEWPROVIDER_CLIENT_SECRET'),
'redirect' => env('APP_URL') . '/oauth/newprovider/callback',
],
```
Add environment variables to `.env.example`:
```bash theme={null}
# New Provider OAuth
NEWPROVIDER_CLIENT_ID=
NEWPROVIDER_CLIENT_SECRET=
```
If using a community Socialite provider, add it to `composer.json`:
```bash theme={null}
cd api && composer require socialiteproviders/newprovider
```
Register in `api/app/Providers/EventServiceProvider.php`:
```php theme={null}
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
'SocialiteProviders\\NewProvider\\NewProviderExtendSocialite@handle',
],
];
```
Update the frontend service configuration:
```javascript theme={null}
// client/composables/query/useOAuth.js
const services = computed(() => {
return [
// ... existing services
{
name: "newprovider",
title: "New Provider",
icon: "mdi:newprovider", // Use appropriate icon
enabled: useFeatureFlag("services.newprovider.client_id", false),
auth_type: "redirect",
},
];
});
```
Update feature flags controller to expose the provider:
```php theme={null}
// api/app/Http/Controllers/Content/FeatureFlagsController.php
public function index(): JsonResponse
{
return response()->json([
// ... existing flags
'services' => [
// ... existing services
'newprovider' => [
'client_id' => !!config('services.newprovider.client_id'),
],
],
]);
}
```
Test both authentication and integration flows with the new architecture:
**Authentication Test:**
1. Start fresh (logged out)
2. Click "Sign in with New Provider"
3. Verify state token is included in redirect URL
4. Complete OAuth flow at provider
5. Verify callback includes state token
6. Check user creation and login success
7. Verify context cleanup (state token should be cleared)
**Integration Test:**
1. Log in with existing account
2. Go to Settings → Connections
3. Click "Connect New Provider"
4. Verify integration scopes are requested (more permissions than auth)
5. Complete OAuth flow
6. Verify provider appears in connected accounts
7. Check that provider record includes full OAuth data
**Workspace Invitation Test (if applicable):**
1. Create a workspace invitation for a specific email
2. Start OAuth flow with `invite_token` parameter
3. Verify email restrictions are applied (if provider supports them)
4. Complete OAuth with invited email address
5. Verify user is added to correct workspace with proper role
Verify state tokens are generated, used for context storage, and cleaned up after use. Check that email restrictions work correctly for invite flows.
If your provider will be used for form integrations, create an integration handler:
```php theme={null}
// api/app/Integrations/Handlers/NewProviderIntegration.php
'required|exists:oauth_providers,id',
'settings' => 'required|array'
];
}
public function handle(array $data): bool
{
// Implement integration logic here
// e.g., send data to external API, create records, etc.
return true;
}
}
```
Register the handler and add frontend integration components as needed.
## Provider Normalization
**Important**: When creating providers that represent the same OAuth service
but with different authentication methods (like Google OAuth vs Google One
Tap), they should be normalized to the same provider name in the database.
OpnForm handles provider normalization through the `getDatabaseProvider()` method in the enum:
```php theme={null}
public function getDatabaseProvider(): string
{
return match ($this) {
self::Google => 'google',
self::GoogleOneTap => 'google', // Both normalize to 'google'
self::Stripe => 'stripe',
self::Telegram => 'telegram',
};
}
```
This ensures that:
* Users can't connect multiple "Google" accounts (regular OAuth + One Tap)
* Provider scopes and permissions are properly merged
* The same OAuth provider record is updated regardless of authentication method
* Integration handlers work consistently with the normalized provider name
When adding new providers, consider if they should be normalized:
* **Same OAuth service, different auth methods**: Normalize (like Google/GoogleOneTap)
* **Different OAuth services**: Don't normalize (like Google/GitHub)
## Widget-Based Providers
For providers that use widget authentication (like Telegram), implement `WidgetOAuthDriver`:
```php theme={null}
$data['user_id'],
'name' => $data['name'],
'email' => $data['email'] ?? null,
'provider_user_id' => $data['user_id'],
'access_token' => $data['auth_token'],
'scopes' => []
];
}
public function isWidgetBased(): bool
{
return true;
}
// Standard OAuthDriver methods (required by interface)
public function getRedirectUrl(): string
{
return ''; // Not used for widget auth
}
public function setRedirectUrl(string $url): self
{
// Not used for widget auth, but required by interface
return $this;
}
public function setScopes(array $scopes): self
{
// Not used for widget auth, but required by interface
return $this;
}
public function setState(string $state): self
{
// Widget-based auth doesn't use state tokens
return $this;
}
public function canCreateUser(): bool
{
return true;
}
public function getScopesForIntent(string $intent): array
{
return []; // Widgets typically don't use OAuth scopes
}
}
```
```vue theme={null}
Connect Widget Provider
```
```javascript theme={null}
// Update service definition for widget providers
{
name: 'widgetprovider',
title: 'Widget Provider',
icon: 'mdi:widget',
enabled: useFeatureFlag('services.widgetprovider.enabled', false),
auth_type: 'widget',
widget_file: 'WidgetProviderWidget'
}
```
## Advanced Configuration
### Request Validation
The system now uses `OAuthRedirectRequest` for structured validation:
```php theme={null}
// api/app/Http/Requests/Auth/OAuthRedirectRequest.php
class OAuthRedirectRequest extends FormRequest
{
public function rules(): array
{
return [
'intent' => 'required|in:auth,integration',
'invite_token' => 'sometimes|string',
'intention' => 'sometimes|string',
'autoClose' => 'sometimes|boolean',
'utm_data' => 'sometimes|array',
];
}
}
```
**Validation Features:**
* **Intent Validation**: Ensures only valid intents (`auth` or `integration`) are accepted
* **Invite Token**: Validates workspace invitation tokens when present
* **Context Parameters**: Validates additional context like `intention` and `autoClose`
* **UTM Tracking**: Validates UTM data for analytics
### Custom Scopes and Parameters
OAuth drivers can customize the authorization request with state tokens:
```php theme={null}
public function getRedirectUrl(): string
{
$parameters = [
'access_type' => 'offline',
'prompt' => 'consent',
'custom_param' => 'value'
];
// Add state parameter for security
if ($this->state) {
$parameters['state'] = $this->state;
}
// Add email restriction parameters (if supported)
if (method_exists($this, 'getEmailRestrictionParameters')) {
$parameters = array_merge($parameters, $this->getEmailRestrictionParameters());
}
return $this->provider
->scopes($this->scopes)
->stateless()
->redirectUrl($this->redirectUrl)
->with($parameters)
->redirect()
->getTargetUrl();
}
```
### Error Handling
The system includes comprehensive error handling in the orchestrator:
```php theme={null}
// In OAuthFlowOrchestrator
public function processCallback(string $provider, array $params): array
{
try {
$providerService = OAuthProviderService::from($provider);
} catch (\ValueError $e) {
abort(400, "Invalid OAuth provider: {$provider}");
}
// Get context using state token
$stateToken = $params['state'] ?? null;
$context = $this->contextService->getContext($stateToken);
if (!$context) {
abort(419, 'OAuth context expired or invalid state');
}
try {
$userData = $this->userDataService->extractFromRedirect($providerService);
return $this->handleIntent($context['intent'], $providerService, $userData, ...);
} catch (\Exception $e) {
report($e);
abort(400, 'Failed to authenticate with the provider. Please try again.');
}
}
```
**Error Handling Features:**
* **Provider Validation**: Validates OAuth provider exists before processing
* **State Token Security**: Returns 419 for expired or invalid state tokens
* **Context Cleanup**: Automatically clears context even on errors
* **Email Validation**: Returns specific errors for email mismatches in invite flows
* **Comprehensive Logging**: All errors are logged for debugging
## Troubleshooting
* Check feature flag configuration in `FeatureFlagsController`
* Verify environment variables are set
* Ensure service is added to `useOAuth` services list
* Check browser console for JavaScript errors
* Verify `redirect` URL in `config/services.php` matches provider
configuration - Check that `APP_URL` environment variable is correct -
Ensure provider OAuth app is configured with correct callback URL
* Check that the OAuth provider is returning the `state` parameter correctly
* Verify that the OAuth flow completes within 5 minutes (context TTL) -
Ensure the state token is being passed correctly in `setState()` method -
Check cache configuration and ensure Laravel cache is working properly
* Verify driver implements `SupportsEmailRestrictions` interface - Check
that `HasEmailRestrictions` trait is being used correctly - Ensure
`getEmailRestrictionParameters()` returns correct parameters for your
provider - Test with a provider that supports login hints (like Google)
* Check `getScopesForIntent()` method returns correct scopes - Verify
provider OAuth app has necessary permissions enabled - Test with minimal
scopes first, then add additional ones - Ensure intent is being passed
correctly (`auth` vs `integration`)
* Verify widget data signature verification logic - Check that widget script
is loaded correctly - Ensure widget callback URL is accessible and correct -
Test widget data extraction and user creation flow - Verify all required
`OAuthDriver` interface methods are implemented (including `setState()`)
* Check that `WorkspaceInviteService` is properly handling invitations
* Verify invite token validation in `OAuthInviteService`
* Ensure email validation is working correctly for invite flows
* Check that workspace and role assignment is working properly
## Best Practices
* **State Token Security**: Always implement `setState()` method properly
for CSRF protection - **Context Cleanup**: Trust the orchestrator to handle
context cleanup automatically - **Email Validation**: Use
`SupportsEmailRestrictions` for workspace invitation flows - **Widget
Verification**: Always verify widget data signatures cryptographically -
**Minimal Scopes**: Use minimal scopes for authentication, full scopes for
integrations - **Error Handling**: Implement comprehensive error handling
and logging
* **Loading States**: Provide clear loading states during OAuth flows -
**Email Hints**: Use email restrictions to pre-fill login forms for better
UX - **Error Messages**: Show specific, actionable error messages for OAuth
failures - **Auto-close**: Use `autoClose` parameter appropriately for
integration flows - **Popup Handling**: Handle popup blockers gracefully
with fallback options
* **State Management**: Trust the orchestrator's efficient state token
system - **Context TTL**: Keep OAuth flows under 5 minutes to avoid context
expiration - **Cache Integration**: Use TanStack Query for efficient data
fetching and cache invalidation - **Service Architecture**: Leverage the
service-oriented architecture for better separation of concerns -
**Cleanup**: Implement proper cleanup for event listeners and avoid memory
leaks
This comprehensive architecture allows OpnForm to support any OAuth provider with a consistent, maintainable codebase that handles all the complexities of modern OAuth flows.
# Cloud vs Self-Hosting
Source: https://docs.opnform.com/deployment/cloud-vs-self-hosting
Understand the differences between cloud and self-hosting options for OpnForm
When deciding between using OpnForm's cloud service or self-hosting, it's important to understand the key differences and benefits of each option. Below is a comparison to help you make an informed decision:
Self-hosting OpnForm does not require an Enterprise license for core
functionality. Free self-hosted instances include OIDC within a 2-user
instance limit. Adding more users and some advanced workspace features
require a self-hosted Enterprise license. See [Self-hosted
License](/deployment/self-hosted-license).
| | Self-Hosted | OpnForm Cloud |
| - | - | - |
| High availability servers | \~\$50+/month | ✅ |
| Load balancer | \~\$15+/month | ✅ |
| Managed database | \~\$45-70+/month | ✅ |
| Instance-wide SMTP provider | \~\$15+/month | ✅ |
| Users | 2 users free; Enterprise license for more users | Based on cloud plan |
| OIDC SSO | Included within the 2-user free self-hosted limit | Based on cloud plan |
| Support | Discord | Live Chat |
| Setup time | Manual | ✅ Up and running in minutes |
| Upgrades | Manual | ✅ Automatic |
| Multi-zone availability | Manual | ✅ |
| Backups | Manual | ✅ Automatic |
| Monitoring | Manual | ✅ |
| Custom code and forks | ✅ | ❌ |
| SSL certificate | Manual | ✅ |
| Where your money goes | 3rd-party services | Improving OpnForm |
### When Should You Self-Host?
There are specific scenarios where self-hosting OpnForm might be the better choice:
* **Regulatory Compliance**: If you need an air-gapped environment for regulatory reasons (e.g., HIPAA compliance).
* **Custom Builds**: If you want to run a custom build or fork of OpnForm with your own customizations.
* **Custom Integrations**: If you need to use community or custom integrations that are not supported in the cloud version.
For most users, the managed OpnForm Cloud service will be the better option due to its ease of use, automatic updates, and comprehensive support.
*This comparison is inspired by the [Metabase Cloud vs Self-Hosting page](https://www.metabase.com/docs/latest/cloud/cloud-vs-self-hosting).*
# Docker Deployment
Source: https://docs.opnform.com/deployment/docker
Deploy OpnForm using Docker
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
Looking to develop OpnForm locally? Check out our [Docker Development
Setup](/deployment/docker-development) guide which provides hot-reload and
other development features.
## Quick Start
1. Clone the repository:
```bash theme={null}
git clone https://github.com/OpnForm/OpnForm.git
cd OpnForm
```
**Important for Windows Users**: Ensure script files maintain LF
(Unix-style) line endings. If you're using Windows, configure Git to
preserve line endings: `bash git config core.autocrlf false ` And
check/fix the artisan script before running setup: `bash # Using Git Bash
or WSL dos2unix api/artisan ` Otherwise, Docker containers may hang at
"Waiting for DB to be ready" during startup.
2. Run the setup script:
```bash theme={null}
chmod +x scripts/docker-setup.sh
./scripts/docker-setup.sh --public-url https://forms.example.com
```
Replace `https://forms.example.com` with the origin that users enter in
their browser. Do not include a path or trailing slash. Use HTTPS for a
production deployment.
The script will:
* Create necessary environment files
* Configure the public backend and frontend URLs
* Pull required Docker images
* Start all containers in production mode
* Display access information
3. Access your OpnForm instance at the public URL you provided.
To evaluate OpnForm only on the local machine, omit `--public-url` and open
`http://localhost`. The script warns when the generated configuration still
points to localhost.
### Configure your public instance URL
Pass the public origin to the setup script to configure all required values in
one command. You can run the same command again to update an existing
installation; existing application keys and secrets are preserved.
```bash theme={null}
./scripts/docker-setup.sh --public-url https://forms.example.com
```
The command sets these values:
| File | Required variables |
| - | - |
| `api/.env` | `APP_URL` and `FRONT_URL` |
| `client/.env` | `NUXT_PUBLIC_APP_URL` and `NUXT_PUBLIC_API_BASE` |
An instance available at `https://forms.example.com` uses that origin for
`APP_URL`, `FRONT_URL`, and `NUXT_PUBLIC_APP_URL`, plus
`https://forms.example.com/api` for `NUXT_PUBLIC_API_BASE`.
If you edit either `.env` file manually, recreate the containers; `docker
compose restart` does not load new environment values.
```bash theme={null}
docker compose down
docker compose up -d
```
See [Public instance URL](/configuration/environment-variables#public-instance-url-self-hosted)
for the complete example and [OIDC SSO Configuration](/configuration/oidc-sso#prerequisites)
before setting up SSO.
### If a reverse proxy is in front of OpnForm
When Caddy, Traefik, Cloudflare, or another reverse proxy forwards traffic to
the Docker ingress, set `TRUSTED_PROXIES` in `api/.env` to the IP address or
CIDR range from which that proxy connects to the ingress. This lets OpnForm use
the real client IP for OIDC and other rate limits.
```bash theme={null}
# Example only: use the actual address or CIDR of your own reverse proxy.
TRUSTED_PROXIES=192.0.2.10,2001:db8:1234::/48
```
Leave it empty when users connect directly to the Docker ingress. Do not set
it to `*`: doing so would make a direct client-provided forwarding header
trustworthy and allow rate-limit bypasses. Recreate the API container after
changing it.
### Initial Setup
After deployment, OpnForm will automatically redirect you to a setup page where you can create your admin account. Simply visit `http://localhost` and you'll be guided through the setup process.
Public registration is disabled in the self-hosted version after setup is
complete. Use the admin account to invite additional users. Free
self-hosted instances are limited to 2 users total across the instance;
activate a self-hosted Enterprise license to add more users.
A self-hosted Enterprise license is optional. Core self-hosted OpnForm works
without a license, including OIDC within the free 2-user limit. Adding more
users and advanced Enterprise features require license activation. See
[Self-hosted License](/deployment/self-hosted-license).
## Architecture
```mermaid theme={null}
graph TD
A[Nginx Proxy] --> B[Frontend - Nuxt SSR]
A --> C[Backend - Laravel API]
C --> D[PostgreSQL]
C --> E[Redis]
C --> F[Queue Worker]
C --> G[Scheduler]
```
### Components
The Nuxt frontend service: - Server-Side Rendered application - Built
with Vue 3 and Tailwind CSS - Handles dynamic rendering and client-side
interactivity - Optimized for production performance
The Laravel API service: - Handles business logic and data persistence -
Provides REST API endpoints - Manages file uploads and processing -
Includes required PHP extensions (pgsql, redis, etc.) - Configured for
PostgreSQL and Redis connections
Background processing services: - **API Worker**: Processes queued jobs
(emails, exports, etc.) - **API Scheduler**: Handles scheduled tasks and
periodic cleanups - Both share the same codebase as the main API
Data storage services: - **PostgreSQL**: Primary database for all
application data - **Redis**: Used for: - Session storage - Cache -
Queue management - Real-time features
The Nginx proxy service: - Routes requests between frontend and backend
* Handles SSL termination - Manages file upload limits - Serves static
assets - Configured for optimal performance
## Docker Images
OpnForm provides pre-built Docker images for easy deployment:
* [OpnForm API Image](https://hub.docker.com/r/jhumanj/opnform-api)
* [OpnForm Client Image](https://hub.docker.com/r/jhumanj/opnform-client)
### Building Custom Images
While we recommend using the official images, you can build custom images if needed:
```bash theme={null}
# Build all images
docker compose build
# Or build specific images
docker build -t opnform-api:local -f docker/Dockerfile.api .
docker build -t opnform-ui:local -f docker/Dockerfile.client .
```
### Custom Configuration
Create a `docker-compose.override.yml` to customize your deployment:
```yaml theme={null}
services:
api:
image: opnform-api:local
environment:
PHP_MEMORY_LIMIT: 1G
ui:
image: opnform-ui:local
ingress:
volumes:
- ./custom-nginx.conf:/etc/nginx/conf.d/default.conf
```
## Environment Variables
For detailed information about environment variables and how to update them in Docker, see our [Environment Variables](/configuration/environment-variables#docker-environment-variables) documentation.
## Maintenance
### Updates
1. Pull latest changes:
```bash theme={null}
git pull origin main
```
2. Update containers:
```bash theme={null}
docker compose pull
docker compose up -d
```
### Monitoring
View container logs:
```bash theme={null}
# All containers
docker compose logs -f
# Specific container
docker compose logs -f api
```
Monitor container health:
```bash theme={null}
docker compose ps
```
## Troubleshooting
### Container Issues
If containers aren't starting:
```bash theme={null}
# View detailed logs
docker compose logs -f
# Recreate containers
docker compose down
docker compose up -d
```
### Database Issues
If database connections fail:
```bash theme={null}
# Check database status
docker compose exec db pg_isready
# View database logs
docker compose logs db
```
If the API container is stuck on "Waiting for DB to be ready":
```bash theme={null}
# Check for line ending issues in the artisan script
# The file should use LF (Unix) line endings, not CRLF (Windows)
# Fix line endings on Unix/Mac:
sed -i 's/\r$//' api/artisan
# Fix line endings on Windows (using Git Bash or WSL):
dos2unix api/artisan
```
**Line Ending Issue**: When using Git or code editors on Windows, line
endings in the `artisan` script may be converted from LF (Unix-style) to
CRLF (Windows-style). This prevents the Docker container from properly
executing the script, causing it to hang at "Waiting for DB to be ready".
Always ensure script files maintain LF line endings.
### Cache Issues
Clear various caches:
```bash theme={null}
# Clear application cache
docker compose exec api php artisan cache:clear
# Clear config cache
docker compose exec api php artisan config:clear
# Clear route cache
docker compose exec api php artisan route:clear
```
### Permission Issues
Fix storage permissions:
```bash theme={null}
docker compose exec api chown -R www-data:www-data storage
docker compose exec api chmod -R 775 storage
```
### Synology Container Manager
When deploying through Synology Container Manager, set the project **Path** to
the cloned OpnForm directory instead of uploading `docker-compose.yml` by
itself. The Compose file also references the generated `api/.env` and
`client/.env` files, as well as `docker/nginx.conf`, using paths relative to the
project directory. Keep this directory structure intact:
```text theme={null}
OpnForm/
├── docker-compose.yml
├── api/
│ └── .env
├── client/
│ └── .env
└── docker/
└── nginx.conf
```
Make sure `docker/nginx.conf` exists as a file before starting the project and
is mounted to `/etc/nginx/templates/default.conf.template`. Mounting the
containing directory instead can leave the ingress container with the default
Nginx configuration. If port 80 is already in use on the NAS, change the
ingress port mapping from `80:80` to another host port, such as `8800:80`.
See [GitHub issue #1005](https://github.com/OpnForm/OpnForm/issues/1005) for a
working Synology Container Manager configuration and its troubleshooting
history.
# Docker Development Setup
Source: https://docs.opnform.com/deployment/docker-development
Set up OpnForm locally for development using Docker
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
## Overview
OpnForm provides a minimal Docker-based development environment optimized for local development. While the full architecture is detailed in our [Docker Deployment](/deployment/docker) guide, the development setup is intentionally lighter and focused on developer experience.
## Prerequisites
* Docker and Docker Compose installed on your machine
* Git installed
* Basic understanding of Docker concepts
## Quick Start
1. Clone the repository:
```bash theme={null}
git clone https://github.com/OpnForm/OpnForm.git
cd OpnForm
```
2. Run the setup script in development mode:
```bash theme={null}
chmod +x scripts/docker-setup.sh
./scripts/docker-setup.sh --dev
```
This script will:
* Pull or build required Docker images
* Start all containers in development mode with embedded configuration
* Display access information
Development mode doesn't require .env files - all configuration is
embedded in docker-compose.dev.yml for simplicity. This includes
`JWT_SKIP_IP_UA_VALIDATION=true`, which disables JWT User Agent checks
for easier local testing. Production Docker deployments should keep this
setting disabled.
3. Access your development environment:
* **Frontend**: [http://localhost:3000](http://localhost:3000) (direct access to Nuxt dev server)
* **API**: [http://localhost/api](http://localhost/api) (proxied through nginx)
### Initial Setup
After starting the development environment, OpnForm will automatically redirect you to a setup page where you can create your admin account. Simply visit `http://localhost:3000/setup` and you'll be guided through the setup process.
Public registration is disabled in the self-hosted version after setup is
complete. Use the admin account to invite additional users. Free
self-hosted instances are limited to 2 users total across the instance;
activate a self-hosted Enterprise license to add more users.
## Architecture
The development setup is intentionally simplified compared to our [Docker Deployment](/deployment/docker) production setup, with a focus on developer experience and faster iteration.
## Codex worktrees
When you create an OpnForm worktree in Codex, Codex uses the repository environment in `.codex/environments` to prepare an isolated local stack automatically. This workflow does not use the shared development containers from `docker-compose.dev.yml`.
The worktree receives its own PostgreSQL Docker volume, API port, and Nuxt port. Laravel and Nuxt run from the current checkout, so the browser always reflects that worktree's files.
Use the Codex actions when available:
* **Start app** starts the database, API, and Nuxt server.
* **Reset DB** runs migrations and `Database\\Seeders\\E2ETestSeeder` only for the current worktree.
* **Stop app** stops Laravel and Nuxt while preserving the local database for a fast restart.
* **Run E2E** resets the worktree database and runs Playwright against its local URLs.
The scripts are also available from the repository root:
```bash theme={null}
./scripts/codex-worktree-setup.sh
./scripts/codex-worktree-up.sh
```
The startup output contains the exact UI URL. Opening its root URL automatically signs in the local Codex admin (`e2e@example.test` / `Abcd@1234`). The reset seed creates three sample forms and five completed submissions across two public forms.
Do not run `docker compose -f docker-compose.dev.yml up` for concurrent Codex worktrees. That stack has shared container names and fixed host ports. Use the Codex worktree actions instead.
### Key Simplifications
* **No .env Files**: All configuration embedded in docker-compose.dev.yml - no environment file generation needed
* **Direct Frontend Access**: Frontend runs on port 3000 with direct access, bypassing nginx proxy
* **Lightweight nginx**: Only handles API routes and Laravel backend, eliminating redundant frontend proxying
* **Simplified networking**: Fewer moving parts, cleaner logs, easier debugging
### Differences from Production
* **No Redis**: Uses file-based caching and sessions instead
* Simpler setup
* No additional service to maintain
* Slightly slower but sufficient for development
* **No Queue Workers**: Uses synchronous job processing
* Jobs run immediately in the main process
* Easier debugging of background tasks
* No need to restart workers after code changes
* **No Scheduler**: Scheduled tasks don't run automatically
* Run scheduled tasks manually when needed
* Less resource usage
* Cleaner logs
* **Embedded Configuration**: No `.env` files needed
* All variables set directly in docker-compose.dev.yml
* Includes secure development keys (APP\_KEY, JWT\_SECRET)
* Pre-configured mail settings using log driver
* Immediate startup without setup scripts
### Development Features
The development setup includes:
#### Frontend Development
* **Hot Module Replacement (HMR)**: Changes to Vue components and styles are instantly reflected without page reload
* **Vue DevTools**: Full integration for component inspection and state management debugging ([learn more](https://devtools.vuejs.org/))
* **Source Maps**: Enabled for easier debugging
* **Fast Refresh**: Preserves component state during updates
* **Error Overlay**: Displays errors directly in the browser
#### Backend Development
* **PHP Hot Reload**: Changes to PHP files are immediately available
* **Xdebug Integration**: Ready for step-by-step debugging
* **Artisan Commands**: Direct access to Laravel's CLI tools
#### Performance Optimizations
The development setup includes smart optimizations for faster iteration:
* **Smart Dependency Install**: Automatically detects when `package.json` has changed and only reinstalls when necessary
* First startup: Full npm install (2-3 minutes)
* Subsequent startups: "Dependencies up to date, skipping install" (5 seconds)
* Automatic detection when dependencies change
* **Persistent Node Modules**: Dependencies stored in Docker volumes to avoid reinstallation
* **Fast Container Restarts**: Skip dependency installation on container restart
### Development URLs
* **Frontend**: [http://localhost:3000](http://localhost:3000)
* Direct access to Nuxt dev server with HMR
* Vue DevTools available
* All client-side routing handled directly
* **API**: [http://localhost/api](http://localhost/api)
* Lightweight nginx proxy to Laravel API
* Routes all backend requests through Laravel's front controller
* PHP-FPM processing for server-side logic
* Supports file uploads and long requests
In development, nginx handles all Laravel routes (including `/`, `/api/*`,
`/forms/*`) through the standard Laravel front controller pattern. The
frontend is accessed directly on port 3000, eliminating redundant proxying.
## File Structure
The development setup mounts your local directories into the containers:
```
OpnForm/
├── api/ # Laravel API (mounted to api container)
│ ├── vendor/ # Preserved in container
│ └── storage/ # Mounted for logs and uploads
├── client/ # Nuxt frontend (mounted to ui container)
│ └── node_modules/ # Preserved in container
└── docker/ # Docker configuration files
```
## Common Tasks
### Running Commands
To run commands in the containers:
```bash theme={null}
# Laravel Artisan commands
docker compose -f docker-compose.dev.yml exec api php artisan [command]
# NPM commands
docker compose -f docker-compose.dev.yml exec ui npm [command]
# Database commands
docker compose -f docker-compose.dev.yml exec db psql -U forge
```
### Accessing Logs
View container logs:
```bash theme={null}
# All containers
docker compose -f docker-compose.dev.yml logs -f
# Specific container (e.g., frontend)
docker compose -f docker-compose.dev.yml logs -f ui
```
### Database Access
The PostgreSQL database is accessible:
* From containers: `host=db`
* From your machine: `localhost:5432`
* Default credentials:
```
Host: localhost
Port: 5432
Database: forge
Username: forge
Password: forge
```
## Troubleshooting
### Container Issues
If containers aren't starting properly:
```bash theme={null}
# Clean everything and restart
./scripts/docker-setup.sh --dev
```
### Frontend Access Issues
If you get **503 errors** when accessing the frontend:
Always use `http://127.0.0.1:3000` instead of `http://localhost:3000`
`localhost` can resolve to IPv6 (`::1`) on some systems, causing connection issues with Docker's port binding.
Verify the Nuxt dev server started successfully: `bash docker compose -f
docker-compose.dev.yml logs ui ` Look for: `"Dependencies up to date,
skipping install"` and `"➜ Local: http://0.0.0.0:3000/"`
```bash theme={null}
docker compose -f docker-compose.dev.yml restart ui
```
### Environment Variables
Development mode uses embedded configuration - no `.env` files needed:
* **APP\_KEY** and **JWT\_SECRET**: Pre-configured development keys
* **Database**: Defaults to `forge/forge` credentials
* **Mail**: Uses log driver for development
* **Storage**: Local filesystem with public visibility
To customize variables, edit them directly in `docker-compose.dev.yml`.
### Permission Issues
If you encounter permission issues:
```bash theme={null}
# Fix storage permissions
docker compose -f docker-compose.dev.yml exec api chmod -R 775 storage
# Fix vendor permissions
docker compose -f docker-compose.dev.yml exec api chmod -R 775 vendor
```
### HMR Issues
If hot reload isn't working:
1. Check browser console for WebSocket errors
2. Ensure ports 3000 and 24678 are available
3. Try restarting the UI container:
```bash theme={null}
docker compose -f docker-compose.dev.yml restart ui
```
### Dependency Installation Issues
If you need to force reinstall dependencies:
```bash theme={null}
# Remove the install marker to force reinstall
docker compose -f docker-compose.dev.yml exec ui rm -f node_modules/.install-complete
# Or clear the entire volume
docker volume rm opnform_client_node_modules
```
## Environment Variables
For production deployments, see our [Environment Variables](/configuration/environment-variables#docker-environment-variables) documentation.
Development mode doesn't use `.env` files. All configuration is embedded in
`docker-compose.dev.yml` for simplicity. To modify variables, edit the
`environment:` section directly in that file.
# Audit Logs
Source: https://docs.opnform.com/deployment/enterprise-features/audit-logs
Use Enterprise audit and compliance visibility on self-hosted OpnForm
Audit Logs are Enterprise features for teams that need stronger operational and compliance visibility.
## What this unlocks
Depending on your license and OpnForm version, audit and compliance features can help teams track important activity across workspaces and forms.
Use audit-oriented features when you need:
* Better visibility into administrative changes
* Compliance-oriented activity records
* Operational review of sensitive workspace changes
## Before you start
Make sure you have:
* An active self-hosted Enterprise license with audit features enabled
* Workspace or instance admin access
* Internal retention and access policies for audit data
## Related pages
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
# Custom Code
Source: https://docs.opnform.com/deployment/enterprise-features/custom-code
Use workspace and form custom code on self-hosted OpnForm
Custom Code lets you inject scripts or CSS into forms for advanced tracking, styling, and integrations.
Custom code can execute JavaScript on public form pages. Enable it only
when you trust the workspace admins who can edit forms and workspace
settings.
## What this unlocks
Depending on your license and instance configuration, Custom Code can unlock:
* Workspace-level custom code
* Form-level custom code
* Custom CSS for advanced form styling
* Advanced tracking scripts and SDK integrations
## Self-hosted safety setting
Self-hosted deployments keep custom code disabled by default for safety. To allow custom code execution on self-hosted forms, set:
```bash theme={null}
CUSTOM_CODE_ENABLE_SELF_HOSTED=true
```
After changing this value, recreate the affected Docker containers so the new environment variable is loaded.
The environment variable controls whether custom code can execute on
self-hosted forms. The Enterprise license controls whether the workspace can
access the underlying advanced branding and custom code features.
## Related pages
* [JavaScript SDK](/embedding/javascript-sdk)
* [Environment Variables](/configuration/environment-variables)
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
# External Storage
Source: https://docs.opnform.com/deployment/enterprise-features/external-storage
Understand Enterprise external storage controls for self-hosted OpnForm
External Storage is an Enterprise area for teams that need storage controls beyond the instance-wide default.
Instance-wide file storage configuration, including local and
S3-compatible storage, is configured separately and does not require an
Enterprise license by itself.
## Instance-wide storage vs Enterprise external storage
Self-hosted OpnForm can use Laravel filesystem configuration to store uploads locally or in S3-compatible storage. This is the default storage configuration for the whole instance.
Enterprise external storage features are workspace or organization-level controls that go beyond the default instance storage setup.
## Configure instance-wide file storage
See [AWS S3 Configuration](/configuration/aws-s3) for the standard self-hosted storage setup.
## Related pages
* [AWS S3 Configuration](/configuration/aws-s3)
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
# Multiple Workspaces and Team Roles
Source: https://docs.opnform.com/deployment/enterprise-features/multiple-workspaces
Use Enterprise multi-workspace and team role controls on self-hosted OpnForm
Self-hosted Enterprise can unlock workspace and team administration features for organizations that manage several teams, clients, or departments in one OpnForm instance.
Self-hosted Community instances are limited to 2 users total across the
whole instance. A self-hosted Enterprise license is required to add more
than 2 users, whether users are invited manually or provisioned through
OIDC.
## What this unlocks
Depending on the features included in your license, multi-org access can unlock:
* Multiple workspaces
* More than 2 users across the instance
* Advanced workspace member roles
* Higher-level organization controls around workspace administration
## Before you start
Make sure you have:
* An active self-hosted Enterprise license with multi-org features enabled
* Workspace admin access
* A clear workspace structure for your teams or clients
## Related pages
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
* [Workspace Users API](/api-reference/workspace-users/list-workspace-users)
# Single Sign-On
Source: https://docs.opnform.com/deployment/enterprise-features/single-sign-on
Use Enterprise SSO features on self-hosted OpnForm
Self-hosted Enterprise unlocks advanced SSO features for teams that need centralized identity management beyond the Community self-hosted limits.
OIDC SSO is available on self-hosted Community instances without an
Enterprise license, but the instance remains limited to 2 users total.
Enterprise is required to add more than 2 users and to use SAML or LDAP SSO.
## What this unlocks
Enterprise SSO covers:
* SAML SSO
* LDAP SSO
* More than 2 self-hosted users when using OIDC provisioning
OIDC configuration is documented in detail and can be used on Community self-hosted instances within the 2-user limit. SAML and LDAP availability depends on the Enterprise features enabled for your installation.
## Before you start
Make sure you have:
* An active self-hosted Enterprise license with SSO enabled for SAML or LDAP
* Workspace admin access
* Identity provider credentials and allowed redirect URI configuration
## Configure OIDC SSO
See [OIDC SSO Configuration](/configuration/oidc-sso) for provider setup, redirect URI behavior, field mappings, group-to-role mappings, and force login mode.
## Related pages
* [OIDC SSO Configuration](/configuration/oidc-sso)
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
# White Label and Advanced Branding
Source: https://docs.opnform.com/deployment/enterprise-features/white-label-branding
Remove OpnForm branding and use advanced branding controls on self-hosted OpnForm
Self-hosted Enterprise can unlock white-label and advanced branding controls for teams that need a more controlled brand experience.
## What this unlocks
Depending on your license, white-label access can unlock:
* Branding removal
* Advanced form branding
* Custom CSS and branding controls
* White-label presentation options
## Community branding vs Enterprise branding
Community self-hosted installations include core form customization. Enterprise white-label features unlock deeper workspace-level branding and branding removal controls.
## Before you start
Make sure you have:
* An active self-hosted Enterprise license with white-label features enabled
* Workspace admin access
* Brand assets and CSS ready if you plan to customize styling
## Related pages
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
* [Custom Code](/deployment/enterprise-features/custom-code)
# Workspace Custom SMTP
Source: https://docs.opnform.com/deployment/enterprise-features/workspace-custom-smtp
Use dedicated SMTP credentials for a specific OpnForm workspace
Workspace Custom SMTP lets a workspace send form email notifications through its own SMTP server instead of the instance-wide mailer.
Workspace Custom SMTP is a self-hosted Enterprise feature. Instance-wide
email sending with `MAIL_*` environment variables remains available without
an Enterprise license.
## What this unlocks
Use Workspace Custom SMTP when:
* A workspace needs emails to come from its own domain.
* Different workspaces need different SMTP credentials.
* You want to separate sender reputation or delivery providers by workspace.
## Before you start
Make sure you have:
* An active self-hosted Enterprise license with Workspace Custom SMTP enabled
* Workspace admin access
* SMTP host, port, encryption, username, password, and sender address
## Configure Workspace Custom SMTP
Open the workspace that needs its own sender, then go to **Email Settings**.
Enter the SMTP host, port, encryption, username, password, and sender address.
Save the workspace email settings. Form email notifications from this workspace will use these SMTP credentials when the feature is available.
## How it relates to instance-wide email
The instance-wide `MAIL_*` configuration is still the default mailer for your self-hosted instance. Workspace Custom SMTP only overrides email sending for the workspace where it is configured.
See [Email Setup](/configuration/email-setup) for the full comparison.
## Related pages
* [Email Setup](/configuration/email-setup)
* [Self-hosted License](/deployment/self-hosted-license)
* [License Activation](/deployment/license-activation)
# License Activation
Source: https://docs.opnform.com/deployment/license-activation
Activate a self-hosted Enterprise license on your OpnForm installation
Use a self-hosted Enterprise license key to unlock Enterprise features and add more than 2 users on your OpnForm instance.
Self-hosted Community instances can be used without an Enterprise license
for up to 2 users total across the instance. OIDC SSO is available within
that 2-user limit. Activate a self-hosted Enterprise license to add more
users or use licensed Enterprise features.
## Prerequisites
Before activating a license, make sure you have:
* A running self-hosted OpnForm installation
* A user account that is an admin of at least one workspace
* A self-hosted Enterprise license key
* Network access from your API container or server to the OpnForm license API
By default, self-hosted license validation uses `https://api.opnform.com`.
## Activate a license from the UI
Sign in as a workspace admin, then open your user settings.
Go to the **Enterprise License** section.
Paste your license key in the **License Key** field.
Click **Activate License**. When activation succeeds, OpnForm refreshes the instance feature flags and enables the licensed Enterprise features.
## License-related environment variables
Most installations can use the default license API settings. Override these variables only when instructed by OpnForm support or when you run a custom license API endpoint.
| Variable | Description |
| - | - |
| `LICENSE_API_ENDPOINT` | Backend license API endpoint. Defaults to `https://api.opnform.com`. |
| `LICENSE_CHECKOUT_SUCCESS_URL` | Checkout success URL used when creating self-hosted Enterprise license checkout sessions. |
| `LICENSE_CHECKOUT_CANCEL_URL` | Checkout cancellation URL used when creating self-hosted Enterprise license checkout sessions. |
| `LICENSE_PORTAL_RETURN_URL` | Return URL used by the billing portal for self-hosted Enterprise licenses. |
| `NUXT_PUBLIC_LICENSE_API_ENDPOINT` | Front-end license API endpoint used to start Enterprise license checkout. Defaults to `https://api.opnform.com`. |
After changing environment variables, recreate the affected Docker
containers. A simple restart does not reload Docker environment variables.
## Network requirements
The API validates the installed license with the configured license API endpoint. The instance sends its license key, instance identifier, and usage metadata needed to validate the license.
OpnForm caches license checks. If the license API is temporarily unavailable after a successful check, the instance can enter a short grace state and continue using licensed features while it retries validation.
## License statuses
| Status | Meaning |
| - | - |
| `active` | The license is valid and Enterprise features are enabled. |
| `grace` | The last successful check is still within the grace window after a temporary license API failure. |
| `expired` | The license is no longer active. Enterprise features are disabled until renewal or activation of a valid key. |
| `activation_limit_reached` | The key is already activated on another self-hosted instance. Contact support to reset the activation. |
| `invalid` | The key is missing, invalid, or could not be validated. |
## Troubleshooting
Confirm that you copied the full key and that the license is active. If the key still fails, contact OpnForm support.
A self-hosted license key is tied to an instance activation. Contact support if you migrated servers or need the activation reset.
Refresh the page and sign in again. If features remain locked, confirm that the API can reach the configured `LICENSE_API_ENDPOINT`, then recreate containers if you changed environment variables.
Make sure a valid license key is installed and that the API can reach the license API endpoint.
## Related pages
* [Self-hosted License](/deployment/self-hosted-license)
* [Environment Variables](/configuration/environment-variables)
* [Docker Environment Variables](/configuration/environment-variables#docker-environment-variables)
# Local Deployment
Source: https://docs.opnform.com/deployment/local-deployment
Set up OpnForm locally for development
The easiest way to get started with OpnForm is through our [official managed service in the Cloud](https://opnform.com/?utm_source=docs\&utm_medium=introduction\&utm_campaign=cloud_version). It takes just 1 minute to start building forms with the free plan, with high availability, backups, security, and maintenance all managed for you.
## Docker Development Setup (Recommended)
We recommend using our Docker-based development environment for the easiest setup experience. It provides hot-reload, debugging support, and all necessary services pre-configured.
See our [Docker Development Setup](/deployment/docker-development) guide to get started with Docker.
## Manual Setup
If you prefer to set up OpnForm manually or can't use Docker, follow the instructions below.
## Requirements
Before proceeding with the local deployment, ensure you have the following prerequisites installed on your system:
* **PHP**: Version 8.0 or higher
* **Composer**: The PHP dependency manager
* **Node.js**: Version 14 or higher
* **NPM** or **Yarn**: Package managers for Node.js
* **MySQL**, or **PostgreSQL**: A supported database system
Make sure these components are properly installed and configured on your local machine before proceeding with the deployment steps.
## Local setup
1. Install Laravel Herd -Download and install Laravel Herd from the official website: [https://herd.laravel.com/](https://herd.laravel.com/)
2. Clone the repository and install dependencies:
```bash theme={null}
git clone https://github.com/OpnForm/OpnForm && cd OpnForm
cd api && composer install
cd ../client && npm install
```
3. Compile assets and run dev server:
```bash theme={null}
cd client && npm run dev # or build
```
4. Set up environment files:
```bash theme={null}
./scripts/setup-env.sh
```
This script will create the necessary `.env` files for both the API and client.
5. Run the migrations:
```bash theme={null}
cd api
php artisan migrate
```
6. Set up Herd:
For detailed instructions on setting up Herd, refer to the [Herd documentation](https://herd.laravel.com/docs).
* Open the Herd application
* Add your OpnForm's `api` directory to Herd
* Herd will automatically configure a local domain for your project
This will start the Nuxt.js development server, typically on `http://localhost:3000`.
7. Access your local OpnForm installation:
* The API will be available at the domain provided by Herd (e.g., `http://opnform.test`)
* The frontend will be accessible at `http://localhost:3000`
# Self-hosted License
Source: https://docs.opnform.com/deployment/self-hosted-license
Understand Community and Enterprise features for self-hosted OpnForm installations
OpnForm is open-source and licensed under AGPLv3. You can self-host the core product without an Enterprise license.
OpnForm also includes Enterprise features under a separate Enterprise License. These features are built for teams that need stronger identity controls, branding, auditability, and workspace-level administration while helping fund ongoing development of the open-source project.
Enterprise code is included with the OpnForm repository and Docker images,
but Enterprise features require an active self-hosted Enterprise license to
use.
## When do I need a license?
| Capability | Community self-hosted | Self-hosted Enterprise |
| - | - | - |
| Run OpnForm for personal use | Yes | Yes |
| Run OpnForm for commercial use | Yes | Yes |
| Modify the AGPLv3 core and redistribute under AGPLv3 | Yes | Yes |
| Use core form builder features | Yes | Yes |
| Use up to 2 users across the instance | Yes | Yes |
| Add more than 2 users across the instance | No | Yes |
| Configure instance-wide SMTP, storage, domains, and OAuth | Yes | Yes |
| Use OIDC SSO | Yes, within the 2-user limit | Yes |
| Use Enterprise workspace features | No | Yes |
| Use Enterprise support and licensing terms | No | Yes |
## What is included in Community self-hosted?
Community self-hosted installations include the core OpnForm product:
* Core form builder and public form pages
* Form submissions and exports
* Embeds and the JavaScript SDK
* Webhooks and standard integrations available in your deployment
* Instance-wide email sending with `MAIL_*` environment variables
* Instance-wide domain configuration with `APP_URL`, `FRONT_URL`, and your reverse proxy
* Instance-wide file storage configuration, including local and S3-compatible storage
* Standard authentication and OAuth providers configured with environment variables
* OIDC SSO for up to 2 users across the instance
Free self-hosted instances are limited to 2 users total across the whole instance. This limit applies regardless of how users are added: registration, invitations, or OIDC provisioning.
## What is included in self-hosted Enterprise?
Self-hosted Enterprise unlocks advanced workspace and organization controls:
| Enterprise area | What it unlocks |
| - | - |
| SSO | SAML and LDAP single sign-on features; OIDC remains available on Community self-hosted within the 2-user limit |
| Multi-org | Multiple workspaces and advanced team role features |
| White label | Branding removal, advanced branding, and white-label controls |
| Workspace Custom SMTP | Dedicated SMTP settings for individual workspaces |
| Audit logs | Audit and compliance-oriented visibility features |
| External storage | Enterprise storage controls beyond the instance-wide default |
| Custom code | Workspace and form custom code when enabled for self-hosted deployments |
An active self-hosted Enterprise license enables every Enterprise feature
listed above for the instance.
## Instance configuration vs Enterprise workspace features
Some OpnForm settings exist at two levels. The instance-wide configuration is available to self-hosted installations without an Enterprise license. The workspace-level Enterprise feature gives specific workspaces their own dedicated controls.
| Area | Community instance-wide configuration | Enterprise workspace feature |
| - | - | - |
| Email | Configure the default sender with `MAIL_*` variables | Configure Workspace Custom SMTP for a specific workspace |
| Storage | Configure the default filesystem or S3-compatible storage | Use Enterprise external storage controls when available |
| Branding | Use core form customization options | Remove OpnForm branding and use advanced white-label controls |
| Authentication | Use standard login, configured OAuth providers, and OIDC within the 2-user limit | Use SAML/LDAP SSO and add more than 2 users |
Custom domains can also exist at two levels: the domain for your whole
OpnForm instance, and custom domains attached to workspaces or forms. See
[Using your own domain](/configuration/custom-domain) for the instance-wide
setup.
## Activate Enterprise features
To unlock Enterprise features on a self-hosted instance, activate a license key in OpnForm.
See [License Activation](/deployment/license-activation) for setup steps, network requirements, and troubleshooting.
## Related pages
* [License Activation](/deployment/license-activation)
* [Workspace Custom SMTP](/deployment/enterprise-features/workspace-custom-smtp)
* [Single Sign-On](/deployment/enterprise-features/single-sign-on)
* [Email Setup](/configuration/email-setup)
* [Environment Variables](/configuration/environment-variables)
# Editor Embed Events
Source: https://docs.opnform.com/embedding/editor-embed
Listen to postMessage events from the OpnForm form editor when embedded in your CMS or application
When you embed the OpnForm **form editor** in an iframe (for example inside a headless CMS), the editor sends `postMessage` events to the parent window so you can react to save, delete, and back-navigation actions.
Editor embedding is available only to approved OpnForm partners with a signed agreement. If OpnForm has not enabled this option for your organization, embedding the editor on your site will not work. To discuss partner access, contact [julien@opnform.com](mailto:julien@opnform.com).
These events are separate from the [JavaScript SDK](/embedding/javascript-sdk) events used for **public form** embeds (`submit`, `pageChange`, etc.).
## Message format
All editor events use this structure:
```javascript theme={null}
{
type: 'opnform:editor:event',
event: 'formSaved' | 'formDeleted' | 'navigateBack',
payload: { /* event-specific data */ }
}
```
## Events
| Event | When it fires | Payload |
| - | - | - |
| `formSaved` | Editor save succeeds (create or update) | `{ form: { id, slug, title, visibility }, isNew: boolean }` |
| `formDeleted` | Form is permanently deleted | `{ form: { id, slug, title, visibility } }` |
| `navigateBack` | Editor navbar back button is clicked | `{ from: { view, route }, to: { view, route } }` |
### View identifiers
The `view` field in `navigateBack` payloads uses stable, human-readable IDs:
| Route name | `view` |
| - | - |
| `home` | `forms_list` |
| `forms-create` | `create_form` |
| `forms-slug-edit` | `edit_form` |
| `forms-slug-show-submissions` | `submissions` |
| `forms-slug-show-share` | `share` |
| `forms-slug-show-integrations` | `integrations` |
| `forms-slug-show-stats` | `stats` |
| `forms-slug-show-summary` | `summary` |
| `forms-slug-show-pdf-templates` | `pdf_templates` |
| (unknown) | raw route name |
## Parent listener example
```javascript theme={null}
window.addEventListener('message', (event) => {
// Restrict to your OpnForm origin in production
if (event.origin !== 'https://your-opnform-domain.com') return
const { type, event: action, payload } = event.data || {}
if (type !== 'opnform:editor:event') return
switch (action) {
case 'formSaved':
console.log('Form saved:', payload.form.slug, payload.isNew ? '(new)' : '(updated)')
break
case 'formDeleted':
console.log('Form deleted:', payload.form.id)
break
case 'navigateBack':
console.log('Navigating back:', payload.from.view, '→', payload.to.view)
break
}
})
```
## Origin targeting
By default, messages are sent to the parent with target origin `*`. For stricter security, pass your CMS origin as a query parameter on the iframe URL:
```
https://your-opnform-domain.com/forms/my-form/edit?_sdkParentOrigin=https%3A%2F%2Fyour-cms.com
```
The editor will then post messages only to that origin.
## Embedding the editor
Embed authenticated editor pages directly in an iframe:
```html theme={null}
```
Your users must be authenticated with OpnForm (session cookie) for editor pages to load.
# JavaScript SDK
Source: https://docs.opnform.com/embedding/javascript-sdk
Programmatically interact with embedded OpnForm forms using our JavaScript SDK
The OpnForm JavaScript SDK enables you to programmatically control embedded forms, listen to events, and build dynamic integrations. It includes automatic iframe resizing and full backward compatibility with existing embeds.
## Installation
Include the SDK script after your form iframe:
```html theme={null}
```
The SDK automatically discovers OpnForm iframes on your page and initializes
them. No additional setup required.
## Automatic campaign attribution
The SDK automatically captures supported campaign parameters from the parent
page and stores them with the submission under `meta.attribution`. Parameters
already present in the iframe `src` take precedence over values from the parent
page.
Supported campaign parameters are `utm_source`, `utm_medium`, `utm_campaign`,
`utm_id`, `utm_term`, `utm_content`, `utm_source_platform`,
`utm_creative_format`, `utm_marketing_tactic`, `gclid`, `gbraid`, `wbraid`,
`dclid`, `fbclid`, `ttclid`, and `msclkid`.
Only these parameters are transmitted to the form. Other URL parameters and the
complete parent URL are not captured. A raw cross-origin iframe without the SDK
can only capture parameters included directly in its own `src`.
## Quick Start
```javascript theme={null}
// Listen to form submission
opnform.on("submit", function (data) {
console.log("Form submitted!", data);
console.log("Submission data:", data.data);
});
// Set a field value
opnform.get("my-form").setField("email", "user@example.com");
// Toggle dark mode
opnform.get("my-form").toggleDarkMode();
```
***
## Events
Listen to form events to trigger custom actions, send data to analytics, or integrate with your application.
### Available Events
| Event | Description | Payload |
| - | - | - |
| `ready` | Form iframe loaded and ready | `{ form, slug, id }` |
| `submit` | Form submitted successfully | `{ form, data, submissionId, completionTime, meta? }` |
| `submitStart` | Submission started | `{ form }` |
| `submitError` | Submission failed | `{ form, errors }` |
| `dataChange` | Form data changed | `{ form, data, changedField, previousValue, newValue }` |
| `error` | Validation error occurred | `{ form, errors }` |
| `pageChange` | Page navigation (multi-page forms) | `{ form, fromPage, toPage, totalPages }` |
| `nextPage` | User proceeded to next page | `{ form, currentPage, totalPages }` |
| `previousPage` | User went back | `{ form, currentPage, totalPages }` |
| `reset` | Form was reset | `{ form }` |
| `show` | Popup form opened | `{ form }` |
| `hide` | Popup form closed | `{ form }` |
### Listening to Events
```javascript Single Event theme={null}
opnform.on('submit', function(data) {
console.log('Form submitted:', data);
console.log('Campaign attribution:', data.meta?.attribution);
});
```
```javascript Multiple Events theme={null}
opnform.on(["nextPage", "previousPage"], function (data) {
console.log("Page changed to:", data.currentPage);
});
```
```javascript Form-Specific Event theme={null}
opnform.get("contact-form").on("submit", function (data) {
console.log("Contact form submitted");
});
```
```javascript One-Time Event theme={null}
opnform.once("ready", function (data) {
console.log("Form is ready");
});
```
### Removing Event Listeners
```javascript theme={null}
// Remove specific handler
const handler = (data) => console.log(data);
opnform.on("submit", handler);
opnform.off("submit", handler);
// Remove all listeners for an event
opnform.off("submit");
```
***
## Form Methods
Access form instances using `opnform.get('form-slug')` and call methods to control the form.
### Field Operations
Set the value of a specific field.
```javascript theme={null}
opnform.get("my-form").setField("email", "user@example.com");
opnform.get("my-form").setField("name", "John Doe");
// Works for hidden fields too
opnform.get("my-form").setField("utm_source", "google");
```
Set multiple field values at once.
```javascript theme={null}
opnform.get("my-form").setFields({
name: "John Doe",
email: "john@example.com",
company: "Acme Inc",
utm_source: "landing_page",
});
```
Get the current value of a field.
```javascript theme={null}
const email = opnform.get("my-form").getField("email");
console.log(email); // "user@example.com"
```
Get all current form data.
```javascript theme={null}
const formData = opnform.get("my-form").getData();
console.log(formData);
// { name: "John", email: "john@example.com", ... }
```
Clear field values.
```javascript theme={null}
// Clear single field
opnform.get("my-form").clearField("email");
// Clear all fields
opnform.get("my-form").clearAll();
```
### Error Handling
```javascript theme={null}
// Check if a field has an error
const hasError = opnform.get("my-form").hasError("email");
// Get error message for a field
const errorMsg = opnform.get("my-form").getError("email");
// Get all errors
const errors = opnform.get("my-form").getErrors();
// { email: "Invalid email format", phone: "Required" }
```
### Theme Control
```javascript theme={null}
// Toggle dark mode
opnform.get("my-form").toggleDarkMode();
// Set specific mode
opnform.get("my-form").setDarkMode(true); // Dark
opnform.get("my-form").setDarkMode(false); // Light
opnform.get("my-form").setDarkMode("auto"); // Follow system
// Check current mode
const isDark = opnform.get("my-form").isDarkMode();
```
### Navigation (Multi-Page Forms)
```javascript theme={null}
// Navigate to specific page
opnform.get("my-form").goToPage(2);
// Navigate forward/backward
opnform.get("my-form").nextPage();
opnform.get("my-form").previousPage();
// Get current page info
const page = opnform.get("my-form").getCurrentPage();
// { index: 1, total: 4 }
// Check navigation availability
const canNext = opnform.get("my-form").canGoNext();
const canPrev = opnform.get("my-form").canGoPrevious();
```
### Form Actions
```javascript theme={null}
// Submit form programmatically
opnform.get("my-form").submit();
// Reset form to initial state
opnform.get("my-form").reset();
// Focus on first error field
opnform.get("my-form").focusFirstError();
```
### Popup Control
For forms embedded as popups:
```javascript theme={null}
// Open popup
opnform.get("my-form").open();
// Close popup
opnform.get("my-form").close();
// Toggle popup
opnform.get("my-form").toggle();
// Check if open
const isOpen = opnform.get("my-form").isOpen();
```
***
## Global SDK Methods
### Form Management
```javascript theme={null}
// Get a specific form instance
const form = opnform.get("my-form-slug");
// Get all forms on the page
const forms = opnform.getAll();
// Check if a form is ready
const isReady = opnform.isReady("my-form-slug");
```
### Initialization Options
```javascript theme={null}
opnform.init({
autoResize: true, // Auto-resize iframes (default: true)
defaultDarkMode: "auto", // 'auto' | true | false
preventRedirect: false, // Prevent redirect after submission
onReady: function (forms) {
// Callback when all forms ready
console.log("All forms ready:", forms);
},
});
```
### Programmatic Form Creation
Use `opnform.create(slugOrUrl, options)` to create an iframe for an existing
form. Pass either its slug or its full share URL.
With a slug, the iframe loads from `https://opnform.com` by default, regardless
of your page's domain or where you load the SDK script.
```javascript theme={null}
opnform.create("my-form-slug", {
container: "#form-container", // CSS selector or element
width: "100%",
height: "auto",
darkMode: false,
onSubmit: function (data) {
console.log("Submitted:", data);
},
});
```
If you have the form's share URL, pass it directly. This works with OpnForm
Cloud, custom domains, subdomains, and self-hosted instances:
```javascript theme={null}
opnform.create("https://forms.example.com/forms/my-form-slug?email=user%40example.com", {
container: "#form-container",
});
```
The URL must use `http://` or `https://` and point to a public form at
`/forms/{slug}`. Its query parameters and fragment are preserved. You can
access the resulting form with `opnform.get("my-form-slug")` as usual.
If you only have the slug, pass `baseUrl` to select a custom domain or a
self-hosted instance. Include the protocol:
```javascript theme={null}
opnform.create("my-form-slug", {
container: "#form-container",
baseUrl: "https://forms.example.com",
});
```
This loads `https://forms.example.com/forms/my-form-slug` inside the iframe.
* A full URL takes precedence over `baseUrl`; its host is never replaced.
* With a slug, `baseUrl` defaults to `https://opnform.com`. The SDK does not
look up the form's custom domain automatically.
* An explicit `darkMode` option overrides the value in the URL. The SDK sets
its own `_sdkToken` and `_sdkParentOrigin` parameters for iframe communication.
* Existing iframes keep their own `src`.
If your integration previously relied on `create()` loading from the page's
domain, pass that instance's `baseUrl` explicitly or use the full share URL.
***
## Integration Examples
### Google Analytics 4
```javascript theme={null}
opnform.on("submit", function (data) {
gtag("event", "form_submission", {
form_id: data.form.id,
form_name: data.form.slug,
completion_time: data.completionTime,
});
});
opnform.on("pageChange", function (data) {
gtag("event", "form_progress", {
form_id: data.form.id,
current_page: data.toPage,
total_pages: data.totalPages,
});
});
```
### Send to Custom API
```javascript theme={null}
opnform.on("submit", async function (data) {
await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: data.data.email,
name: data.data.name,
source: "opnform",
formId: data.form.id,
}),
});
});
```
### Error Tracking
```javascript theme={null}
opnform.on("submitError", function (data) {
console.error("Form submission failed:", data.errors);
// Send to error tracking service
Sentry.captureMessage("Form submission failed", {
extra: { formId: data.form.id, errors: data.errors },
});
});
opnform.on("error", function (data) {
console.log("Validation errors:", data.errors);
});
```
### Dynamic Field Population
```javascript theme={null}
opnform.once("ready", function () {
// Get data from URL params
const params = new URLSearchParams(window.location.search);
opnform.get("my-form").setFields({
email: params.get("email") || "",
utm_source: params.get("utm_source") || "direct",
utm_campaign: params.get("utm_campaign") || "",
});
});
```
***
## Custom Code Integration
The SDK is automatically available when using the **Custom Code** feature in OpnForm. You can add custom JavaScript directly in your form or workspace settings, and the `window.opnform` SDK will be ready to use.
Custom Code works without iframes - the SDK is initialized directly on your
form page, giving you full access to form methods and events.
### Adding Custom Code
1. Go to your form's **Settings** → **Custom Code** (or Workspace Settings for workspace-wide code)
2. Add your JavaScript code in a `
```
### Example: Facebook Pixel
```html theme={null}
```
### Example: Conditional Logic with External Data
```html theme={null}
```
### Example: Custom Validation Feedback
```html theme={null}
```
### Example: Live Data Change Tracking
```html theme={null}
```
### Accessing Form Data Directly
```html theme={null}
```
***
## Backward Compatibility
Existing embeds using `initEmbed()` continue to work without changes. The
SDK provides full backward compatibility.
```html theme={null}
```
We recommend upgrading to the new SDK to access event callbacks and
programmatic control features.
***
## Troubleshooting
Ensure the iframe has loaded and the `id` attribute matches the form slug:
```html theme={null}
```
Use `opnform.getAll()` to see discovered forms.
Make sure the SDK script is loaded after the iframe:
```html theme={null}
```
Commands require the form to be ready. Use the `ready` event:
```javascript theme={null}
opnform.once("ready", function () {
opnform.get("my-form").setField("email", "test@example.com");
});
```
The SDK includes iFrame Resizer. If resize isn't working, ensure:
* No CSS `max-height` restricting the iframe
* The form is not in `focused` presentation style (which has fixed height)
# Computed Variables
Source: https://docs.opnform.com/features/computed-variables
Create dynamic calculated fields using Excel-like formulas that can be used throughout your form and integrations.
Computed variables allow form owners to define calculated values using formulas. These variables can reference form fields, use functions, and even reference other computed variables. They work seamlessly with mentions throughout your form.
## Overview
Computed variables are powerful calculated fields that:
* **Evaluate live** during form fill (client-side)
* **Evaluate on submission** for integrations and notifications (server-side)
* **Support Excel-like formulas** with math, text, and logic functions
* **Chain together** to create complex calculations
* **Work with mentions** anywhere you can use field references
## Creating Computed Variables
### Accessing the Variables Tab
1. Open your form in the editor
2. Click on **Settings** (gear icon)
3. Navigate to the **Variables** tab
4. Click **Add Variable** to create a new computed variable
### Variable Configuration
Each computed variable has:
| Field | Description |
| - | - |
| **Name** | A descriptive name for the variable (e.g., "Total Price") |
| **Formula** | The calculation formula using fields, functions, and operators |
### Formula Editor
The formula editor provides:
* **Field picker** - Insert form fields as references
* **Function picker** - Browse and insert available functions
* **Live validation** - Real-time syntax checking
* **Preview** - See sample output as you type
## Formula Syntax
### Field References
Reference form fields using curly braces:
```
{field_id}
```
In the editor, you can click the field button to insert fields as interactive pills.
### Operators
| Operator | Description | Example |
| - | - | - |
| `+` | Addition / Concatenation | `{price} + {tax}` |
| `-` | Subtraction | `{total} - {discount}` |
| `*` | Multiplication | `{price} * {quantity}` |
| `/` | Division | `{total} / {count}` |
| `=` | Equals | `{status} = "active"` |
| `<>` | Not equals | `{status} <> "inactive"` |
| `<` `>` | Less/Greater than | `{age} > 18` |
| `<=` `>=` | Less/Greater or equal | `{score} >= 90` |
### Text Concatenation
Use `+` or `CONCAT()` to join text:
```
"Hello, " + {name} + "!"
CONCAT({first_name}, " ", {last_name})
```
## Available Functions
### Math Functions
| Function | Description | Example |
| - | - | - |
| `SUM(...)` | Add multiple values | `SUM({item1}, {item2}, {item3})` |
| `AVERAGE(...)` | Calculate mean | `AVERAGE({score1}, {score2})` |
| `MIN(...)` | Find minimum | `MIN({price1}, {price2})` |
| `MAX(...)` | Find maximum | `MAX({bid1}, {bid2})` |
| `ROUND(value, decimals)` | Round number | `ROUND({total}, 2)` |
| `FLOOR(value)` | Round down | `FLOOR({price})` |
| `CEIL(value)` | Round up | `CEIL({quantity})` |
| `ABS(value)` | Absolute value | `ABS({difference})` |
| `MOD(value, divisor)` | Remainder | `MOD({number}, 2)` |
| `POWER(base, exp)` | Exponentiation | `POWER({base}, 2)` |
| `SQRT(value)` | Square root | `SQRT({area})` |
### Text Functions
| Function | Description | Example |
| - | - | - |
| `CONCAT(...)` | Join text | `CONCAT({first}, " ", {last})` |
| `UPPER(text)` | Uppercase | `UPPER({name})` |
| `LOWER(text)` | Lowercase | `LOWER({email})` |
| `TRIM(text)` | Remove whitespace | `TRIM({input})` |
| `LEFT(text, n)` | First n characters | `LEFT({code}, 3)` |
| `RIGHT(text, n)` | Last n characters | `RIGHT({phone}, 4)` |
| `MID(text, start, len)` | Substring | `MID({ssn}, 4, 2)` |
| `LEN(text)` | Text length | `LEN({message})` |
| `SUBSTITUTE(text, old, new)` | Replace text | `SUBSTITUTE({url}, "http", "https")` |
| `REPT(text, n)` | Repeat text | `REPT("*", {rating})` |
### Logic Functions
| Function | Description | Example |
| - | - | - |
| `IF(condition, true_val, false_val)` | Conditional | `IF({score} >= 90, "A", "B")` |
| `AND(...)` | All true | `AND({age} >= 18, {consent})` |
| `OR(...)` | Any true | `OR({premium}, {trial})` |
| `NOT(value)` | Negate | `NOT({disabled})` |
| `ISBLANK(value)` | Check empty | `ISBLANK({optional})` |
| `ISNUMBER(value)` | Check number | `ISNUMBER({input})` |
| `ISTEXT(value)` | Check text | `ISTEXT({response})` |
| `IFBLANK(value, fallback)` | Default if empty | `IFBLANK({nickname}, {name})` |
| `COALESCE(...)` | First non-empty | `COALESCE({mobile}, {home}, {work})` |
| `SWITCH(val, case1, res1, ...)` | Multiple conditions | `SWITCH({grade}, "A", 4, "B", 3, 0)` |
| `CHOOSE(index, val1, val2, ...)` | Pick by index | `CHOOSE({option}, "One", "Two", "Three")` |
## Common Use Cases
### Calculate Total Price
```
{price} * {quantity}
```
### Apply Discount
```
{subtotal} * (1 - {discount_percent} / 100)
```
### Calculate Tax and Total
```
// Tax variable
{subtotal} * 0.1
// Total variable (referencing tax)
{subtotal} + {cv_tax}
```
### Conditional Pricing
```
IF({membership} = "premium", {price} * 0.8, {price})
```
### Grade Calculator
```
IF({score} >= 90, "A",
IF({score} >= 80, "B",
IF({score} >= 70, "C",
IF({score} >= 60, "D", "F"))))
```
### Full Name
```
CONCAT({first_name}, " ", {last_name})
```
### Email Domain
```
RIGHT({email}, LEN({email}) - FIND("@", {email}))
```
## Using Computed Variables
### In Mentions
Computed variables appear in the mention dropdown alongside form fields. Look for the 📊 icon:
* In **Thank You Page** content
* In **Email notifications** (subject, body, reply-to)
* In **Redirect URLs**
* In **Integration payloads**
### In Conditional Logic
Use computed variables in conditional logic rules:
1. Open the Logic tab for a field
2. Add a condition
3. Select your computed variable from the dropdown
4. Set the comparison operator and value
Example: Show a field only when total is over \$100:
* **Field:** `cv_total`
* **Condition:** `greater_than`
* **Value:** `100`
## Variable Dependencies
Computed variables can reference other computed variables:
```
// cv_subtotal
{price} * {quantity}
// cv_tax (references cv_subtotal)
{cv_subtotal} * {tax_rate}
// cv_total (references both)
{cv_subtotal} + {cv_tax}
```
**Circular Dependencies:** The system automatically detects and prevents circular references. Variable A cannot reference Variable B if B already references A (directly or indirectly).
## Technical Details
### Evaluation Timing
| Context | When Evaluated |
| - | - |
| Form fill | Live, as field values change |
| Conditional logic | Before rendering each field |
| Thank you page | After submission |
| Email notifications | When email is sent |
| Integrations | When integration runs |
### Data Storage
Computed variable values are **not stored** in submission records. They are calculated on-demand when needed. This ensures:
* Always up-to-date calculations
* Smaller database storage
* Formula changes affect past submissions when viewed
### Error Handling
If a formula cannot be evaluated (e.g., division by zero, missing field), it returns `null`. Use `IFBLANK()` or `COALESCE()` to provide fallback values:
```
IFBLANK({cv_calculation}, "N/A")
```
## Best Practices
**Naming:** Use descriptive names like "Total Price" or "Shipping Cost" rather than generic names like "Calculation 1".
**Validation:** Test your formulas with sample data using the preview feature before saving.
**Modularity:** Break complex calculations into smaller variables that reference each other. This makes formulas easier to understand and debug.
**Performance:** While computed variables are efficient, avoid extremely complex chains of 10+ interdependent variables for best performance.
# Data Fetching
Source: https://docs.opnform.com/front-end/data-fetching
How we fetch and mutate server data in the front-end with TanStack Query.
Authentication tokens are stored in **cookies** after login. `client/composables/useOpnApi.js` automatically injects the
`Authorization: Bearer ` header on every request, and the global middleware
`client/middleware/01.check-auth.global.js` pre-loads the current user & workspaces during navigation.
If you need to generate new tokens (e.g. for external scripts) see the [Authentication](../api-reference/introduction#authentication) docs.
## Why TanStack Query?
We use [TanStack Query](https://tanstack.com/query/latest) to handle **server state**—data that lives in the database and is accessed through our API. It gives us:
* Automatic caching & background refetching
* Declarative loading / error states
* Built-in mutations with optimistic updates
* DevTools for debugging
For purely **client-side UI state** (theme, modals, etc.) we still rely on [Pinia](https://pinia.vuejs.org/) – see `client/stores/app.js` for an example.
## Reading data
```vue theme={null}
Loading…
{{ error.message }}
{{ ws.name }}
```
A query composable always returns a function (e.g. `list`) that you **call** to register the query. The options object you pass is forwarded directly to TanStack Query in case you need `staleTime`, `enabled`, etc.
## Mutating data
`mutateAsync` returns a Promise, so we recommend chaining handlers with `.then().catch()` to keep control flow explicit:
```vue theme={null}
```
### Mutations with URL parameters
For endpoints that require IDs in the URL (like `/forms/{formId}/integrations/{integrationId}`), pass the required IDs when creating the mutation factory. This ensures proper cache invalidation and prevents stale closure issues:
```vue theme={null}
```
**Why this pattern?**
* The mutation factory receives the IDs and can properly update query cache keys
* No need to pass IDs in the `mutateAsync()` call
* Reactive IDs ensure cache updates work correctly when props change
* Cleaner separation between mutation setup and execution
#### Defining mutations with URL parameters in composables
In your query composable, define mutation factories that accept the required IDs as parameters:
```javascript theme={null}
// client/composables/query/forms/useFormIntegrations.js
import { useQueryClient, useMutation } from '@tanstack/vue-query'
import { toValue } from 'vue'
import { formsApi } from '~/api/forms'
export function useFormIntegrations() {
const queryClient = useQueryClient()
const createIntegration = (formId, options = {}) => {
return useMutation({
mutationFn: (data) => formsApi.integrations.create(toValue(formId), data),
onSuccess: (response) => {
const newIntegration = response.form_integration
const currentFormId = toValue(formId)
// Update integrations list cache
queryClient.setQueryData(['forms', currentFormId, 'integrations'], (old) => {
if (!old) return [newIntegration]
if (!Array.isArray(old)) return old
return [...old, newIntegration]
})
// Cache the individual integration
queryClient.setQueryData(['forms', currentFormId, 'integrations', newIntegration.id], newIntegration)
},
...options
})
}
const updateIntegration = (formId, integrationId, options = {}) => {
return useMutation({
mutationFn: (data) => formsApi.integrations.update(toValue(formId), toValue(integrationId), data),
onSuccess: (response) => {
const updatedIntegration = response.form_integration
const currentFormId = toValue(formId)
const currentIntegrationId = toValue(integrationId)
// Update individual integration cache
queryClient.setQueryData(['forms', currentFormId, 'integrations', currentIntegrationId], updatedIntegration)
// Update in integrations list
queryClient.setQueryData(['forms', currentFormId, 'integrations'], (old) => {
if (!Array.isArray(old)) return old
return old.map(integration =>
integration.id === currentIntegrationId ? { ...integration, ...updatedIntegration } : integration
)
})
},
...options
})
}
return {
createIntegration,
updateIntegration,
// ... other methods
}
}
```
Always use `toValue()` to unwrap reactive references (refs, computed) when building query keys or making API calls. This ensures the actual values are used rather than reactive objects.
**Key patterns:**
* **Factory functions**: Return `useMutation()` instances rather than calling them directly
* **Reactive ID handling**: Use `toValue()` to extract values from refs/computed properties
* **Consistent cache keys**: Match the hierarchical structure used in queries
* **Optimistic updates**: Update cache immediately in `onSuccess` to avoid refetching
### Optimistic updates & cache layer
All mutations live in their composable (`client/composables/query/*`). After a successful API call they update the cache **manually** using `queryClient.setQueryData` so the UI reflects changes instantly *without* needing an extra network round-trip.
If you need to invalidate and refetch, every composable exposes `invalidate()` which calls `queryClient.invalidateQueries` for its namespace:
```js theme={null}
import { useWorkspaces } from '@/composables/query'
useWorkspaces().invalidate() // refetch every workspace query
```
## Anatomy of a composable (quick tour)
Take `useWorkspaces.js` as an example:
1. **Queries** – `list()`, `detail(id)`
2. **Mutations** – `create()`, `update()`, `remove()`
3. **Utilities** – `invalidate()`
Each query/mutation is a thin wrapper around `workspaceApi.*`, keeps its own `queryKey`, and touches only the part of the cache it owns. This isolation makes cache behaviour predictable and prevents accidental over-fetching.
### Waiting for completion with `suspense()`
Every query instance exposes a `suspense()` method that returns a Promise resolving once the data is loaded. Our auth middleware uses it to block navigation until the user and workspace lists are ready:
```js theme={null}
// client/middleware/01.check-auth.global.js (excerpt)
const userQuery = useAuth().user()
const workspacesQuery = useWorkspaces().list()
await Promise.all([
userQuery.suspense(),
workspacesQuery.suspense(),
])
```
## Loading & error states
Every query exposes booleans such as `isLoading`, `isFetching`, `isError`. Mutations expose `isPending`. Use them to drive spinners, disabled states, or retry logic.
`isFetching && data` is perfect for subtle "background update" indicators.
## TL;DR
* Use **TanStack Query composables** for anything that comes from the API.
* Use **Pinia** only for UI or session state.
* Mutate with `mutateAsync().then().catch()`.
* Call `invalidate()` when you really need a refetch—otherwise rely on optimistic updates.
# Forms & Validation
Source: https://docs.opnform.com/front-end/forms
How to build forms with validation, error handling, and API integration using our form system.
Forms in OpnForm handle both client-side validation and server-side API integration. They work seamlessly with [TanStack Query mutations](./data-fetching#mutating-data) for optimal user experience.
## Form System Overview
Our form system consists of three main parts:
1. **Form Class** (`client/composables/lib/vForm/Form.js`) - Core form state management
2. **useFormInput Composable** (`client/components/forms/useFormInput.js`) - Input component logic
3. **Form Components** (`client/components/forms/`) - Reusable input components
## Creating a Basic Form
### 1. Initialize the Form
```vue theme={null}
```
The `useForm` composable creates a reactive form instance with:
* **Data binding** - automatic two-way data binding
* **Validation state** - tracks errors and validation status
* **Loading state** - busy/successful flags for UI feedback
* **Error handling** - automatic error extraction from API responses
### 2. Create the Form Template
```vue theme={null}
```
### 3. Handle Form Submission
```vue theme={null}
```
## Form Input Components
### Using Form Inputs
All form inputs accept a common set of props through the `useFormInput` composable:
```vue theme={null}
:form="form"
label="Email Address"
:required="true"
placeholder="Enter your email"
help="We'll never share your email"
:disabled="form.busy"
/>
```
### Available Input Components
* `` - Text, email, password, etc.
* `` - Multi-line text
* `` - Checkboxes
* `` - Dropdown selects
* ... and more!
### Custom Input Components
Create custom inputs using the `useFormInput` composable:
```vue theme={null}