# 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. Create token ## 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} ``` ```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} ``` 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} ``` ## Validation & Error Handling ### Client-Side Validation Form validation happens automatically: ```vue theme={null} ``` ### Server-Side Validation The form automatically handles API validation errors: ```javascript theme={null} // API returns validation errors like: { "errors": { "email": ["The email field is required."], "password": ["The password must be at least 8 characters."] } } // Form automatically displays these errors on the respective inputs ``` ### Precognition (Live Validation) Use real-time server validation as users type with [Laravel Precognition](https://laravel.com/docs/12.x/precognition). ```vue theme={null} ``` ### Error States Access validation state programmatically: ```vue theme={null} ``` ## Integration with TanStack Query Forms integrate seamlessly with TanStack Query mutations for optimal caching and state management: ```vue theme={null} ``` See [Data Fetching](./data-fetching#mutating-data) for more details on how mutations work with caching and optimistic updates. ## Form State Management ### Form Methods ```javascript theme={null} const form = useForm({ name: 'John', email: 'john@example.com' }) // Get form data const data = form.data() // { name: 'John', email: 'john@example.com' } // Reset form to original state form.reset() // Clear form and set new data form.resetAndFill({ name: 'Jane', email: 'jane@example.com' }) // Clear errors and state form.clear() // Fill form with partial data form.fill({ name: 'Updated Name' }) ``` ### Form State Properties ```javascript theme={null} // Loading state form.busy // Currently submitting form.successful // Last submission was successful form.recentlySuccessful // Recently successful (with timeout) // Error state form.errors.any() // Has any errors form.errors.get('field') // Get specific field error form.errors.has('field') // Check if field has error ``` ## Complete Example Here's a complete working example based on the LoginForm component: ```vue LoginForm.vue theme={null} ``` ## Best Practices **Form State Management**: Always use the `useForm` composable rather than managing form state manually. It handles validation, errors, and loading states automatically. **Error Handling**: Let the form handle API validation errors automatically. Only add custom error handling for specific business logic. **File Uploads**: When handling file uploads, the form will automatically serialize data as `FormData`. Make sure your API endpoint accepts `multipart/form-data`. **Validation**: Combine client-side validation (required, email, etc.) with server-side validation for the best user experience. # JavaScript SDK Source: https://docs.opnform.com/front-end/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); }); ``` ```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") || "", }); }); ``` *** ## 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) # Use OpnForm with AI assistants through MCP Source: https://docs.opnform.com/integrations/mcp Connect ChatGPT, Cursor, Claude Code, Codex, or another MCP client to OpnForm Cloud or a self-hosted OpnForm instance. OpnForm includes a remote [Model Context Protocol](https://modelcontextprotocol.io) server. It lets AI assistants build forms from a conversation, show you a private preview, manage forms, and help you work with submissions. The setup depends on whether you use **OpnForm Cloud** or a **self-hosted OpnForm instance**. ## Choose your OpnForm setup The hosted MCP server is already enabled at: ```text theme={null} https://api.opnform.com/mcp ``` You can start a private form draft without an OpnForm account. Connect your account with OAuth only when you want the assistant to save forms, manage existing forms, or read submissions. If you are signed in to OpnForm, open **Settings → MCP & AI agents** to find the server URL and ready-to-copy instructions for your AI client. There is no activation switch for OpnForm Cloud. Your MCP endpoint uses the API origin of your instance: ```text theme={null} https://forms.example.com/mcp ``` MCP is disabled by default. An administrator must configure OAuth and enable MCP from **Settings → MCP & AI agents**. Self-hosted MCP connections always require an OpnForm account; guest draft creation is not exposed. ## Create a form with OpnForm Cloud You can ask a compatible AI assistant to create a form before connecting your OpnForm account. Explain the form's goal, audience, questions, and any requirements you already know. The assistant validates the definition before creating a private draft. Open the preview returned by the assistant. Test the questions and flow, then ask the assistant to add, remove, or revise fields. The preview is private and does not publish the form. Select **Open in OpnForm** when you want to continue in the visual editor. You can customize the design before creating an account or signing in to save the form. Complete OAuth when you ask the assistant to save the draft, manage your existing forms, or access submissions. OpnForm displays the requested connection before you approve it. Guest draft links are private bearer links. Do not share them publicly. If a preview expires, ask the assistant to generate a fresh preview. ## Enable MCP on a self-hosted instance ### Prerequisites Before you enable MCP, verify that your instance has: * public HTTPS values for `APP_URL` and `FRONT_URL`; * a valid Laravel Passport key pair; * persistent storage for the Passport keys so existing OAuth connections survive container replacement; * a trusted MCP client that supports remote streamable HTTP servers and OAuth. Do not expose a production MCP endpoint over plain HTTP. Localhost URLs are suitable only for local client testing. Sign in as an administrator, then open **Settings → MCP & AI agents**. OpnForm verifies the public URLs and Passport keys. If a requirement is missing, the page identifies what you need to configure before activation. Turn on **Enable MCP**. Enable MCP in the settings of a self-hosted OpnForm instance The status changes to **MCP is available** and the page displays your `/mcp` endpoint. The switch is stored in the OpnForm database and applies without restarting the application. Until an administrator changes it, OpnForm uses `MCP_ENABLED` from the API environment as the default: ```dotenv theme={null} MCP_ENABLED=false MCP_OBSERVABILITY_ENABLED=true ``` Self-hosted instances use a strict authenticated flow. They expose validation and account-scoped MCP tools, but not the cloud guest draft endpoints. ## Connect an AI assistant For OpnForm Cloud, use `https://api.opnform.com/mcp`. For a self-hosted instance, copy the endpoint displayed in **Settings → MCP & AI agents**. Copy the MCP server URL from OpnForm settings The settings page generates the correct command or configuration for your client. Select your client, then copy the displayed value. Select an AI client and copy its generated MCP configuration Use **Install in Cursor** for one-click setup, or copy the generated `mcpServers` JSON configuration. For OpnForm Cloud, run: ```bash theme={null} claude mcp add --transport http opnform https://api.opnform.com/mcp ``` Replace the URL with your own `/mcp` endpoint when you use a self-hosted instance. On a plan that supports custom MCP apps, enable [ChatGPT developer mode](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt), create an app, and paste the server URL. Select OAuth when prompted. ChatGPT discovers the authorization metadata from the endpoint. ChatGPT plan and workspace controls determine whether custom apps and write actions are available. An administrator may need to enable developer mode before you can add the server. For OpnForm Cloud, run: ```bash theme={null} codex mcp add opnform --url https://api.opnform.com/mcp ``` Replace the URL with your own `/mcp` endpoint when you use a self-hosted instance. Use a streamable HTTP configuration in any OAuth-capable MCP client: ```json theme={null} { "mcpServers": { "opnform": { "type": "http", "url": "https://api.opnform.com/mcp" } } } ``` Open **Portable Agent Plugin configuration** in OpnForm settings if your client supports [Agent Plugins](https://agent-plugins.org). To share instructions for a particular client, include the selected client in the settings URL: ```text theme={null} https://opnform.com/?user-settings=mcp&agent=cursor ``` On self-hosted instances, replace `https://opnform.com` with your frontend URL. Supported `agent` values are `cursor`, `claude_code`, `chatgpt`, `codex`, and `other`. ## Authenticate your OpnForm account Form, workspace, and submission tools use OAuth. The MCP client discovers OpnForm's authorization metadata, opens the OpnForm consent screen, and uses authorization code flow with PKCE `S256`. You never paste an OpnForm password or access token into a conversation. On OpnForm Cloud, guest form creation and preview remain available before authentication. On self-hosted instances, guest draft creation is unavailable. Schema resources, the field catalog, and `validate_form_definition` remain available before authentication because they do not read or store account data. After authenticating a Codex connection, start a new conversation with OpnForm selected so the MCP client loads the stored credential. A connected user can revoke the current access and refresh tokens by sending an authenticated `DELETE` request to `/mcp-oauth/session`. ## Available capabilities | Capability | OpnForm Cloud | Self-hosted | | - | - | - | | Create and preview a guest draft | Available without an account | Not available | | Validate a form definition | Available before authentication | Available before authentication | | List accessible workspaces | OAuth required | OAuth required | | List, inspect, create, and update forms | OAuth required | OAuth required | | Publish or move a form to trash | OAuth and explicit confirmation | OAuth and explicit confirmation | | Search, analyze, and export submissions | OAuth required, read-only | OAuth required, read-only | | Enable or disable the MCP server | Managed by OpnForm | Self-hosted administrator | Workspace administration and submission mutations are unavailable. MCP does not expose permanent form deletion, form restoration, submission deletion, or submission restoration. Normal workspace permissions and plan limits continue to apply. If saving a form disables unavailable premium features, the tool response lists those changes. Before validating or saving a definition, OpnForm silently normalizes issues it can repair without guessing intent. For example, it removes empty or invalid display-logic blocks, repairs stale logic reference metadata, discards unusable entries from select options, and removes invalid computed variables together with formulas or logic that become invalid as a result. Ambiguous changes still return a precise error path, such as an empty select field, a duplicate field ID, or a malformed form block. ### Computed variables and display logic The versioned form-definition schema and field catalog document computed variables, display-logic actions, condition groups, and the operators available for each reference type. Formula references use technical IDs in braces: ```json theme={null} { "computed_variables": [ { "id": "cv_priority_score", "name": "Priority score", "formula": "{budget} * 1.2", "result_type": "number" } ] } ``` A logic condition may use that calculated value by setting `property_meta.type` to `computed`: ```json theme={null} { "hidden": true, "logic": { "conditions": { "operatorIdentifier": "and", "children": [ { "identifier": "cv_priority_score", "value": { "operator": "greater_than", "property_meta": { "id": "cv_priority_score", "type": "computed" }, "value": 10000 } } ] }, "actions": ["show-block"] } } ``` With `patch_form_draft`, replace the complete `computed_variables` list through `set_form_values`, and add or modify a block's `logic` through `add_block` or `update_block`. The draft is normalized and validated atomically before its version changes. ## Self-hosted OAuth configuration The following settings apply only to self-hosted instances. Configure the callback destinations accepted by your instance: | Variable | Purpose | | - | - | | `MCP_OAUTH_REDIRECT_DOMAINS` | Comma-separated HTTPS or loopback callback origins accepted during dynamic client registration. | | `MCP_OAUTH_CUSTOM_SCHEMES` | Comma-separated callback URI schemes accepted for trusted native clients. | | `MCP_AUTHORIZATION_SERVER` | Optional external authorization-server URL advertised by MCP discovery. | The consent screen displays the callback destination before approval. OAuth authorization requests and login tickets use atomic cache locks. If you run multiple application instances, configure Laravel's default cache as a shared, lock-capable store such as Redis or DynamoDB. File and array cache drivers are suitable only for single-instance development. ## Self-hosted rate limits The default limits are permissive for normal agent use: | Variable | Default | Scope | | - | -: | - | | `MCP_RATE_LIMIT_PER_MINUTE` | `120` | All MCP requests, per OAuth user or unauthenticated client IP | | `MCP_RATE_LIMIT_PER_HOUR` | `3000` | All MCP requests, per OAuth user or unauthenticated client IP | | `MCP_SUBMISSION_EXPORTS_PER_MINUTE` | `5` | Exports, per user and form | | `MCP_SUBMISSION_EXPORTS_PER_HOUR` | `30` | Exports, per user and form | | `FORM_SUMMARY_RATE_LIMIT_PER_MINUTE` | `30` | REST and MCP submission summaries, shared per user | ## Privacy and observability When `MCP_OBSERVABILITY_ENABLED=true`, a self-hosted instance records only the MCP method, tool name, authentication mode, outcome, HTTP status, and duration. OpnForm does not include request arguments, OAuth tokens, form definitions, submission values, export URLs, user identifiers, or IP addresses in MCP telemetry or structured MCP request logs. Set `MCP_OBSERVABILITY_ENABLED=false` to disable MCP usage events and their structured logs. General anonymous instance telemetry remains controlled by `OPNFORM_ANONYMOUS_TELEMETRY_DISABLED`. ## Troubleshooting This switch appears only on self-hosted instances. Verify that `APP_URL` and `FRONT_URL` use public HTTPS origins and that the API container can read a persistent Passport key pair. Reload the settings page after correcting the configuration. Complete the OAuth flow from the MCP client. Guest drafts on OpnForm Cloud do not authenticate your account automatically. Start a new conversation after authentication. Some clients retain the tool and authentication state that existed when the conversation started. Ask the assistant to create a fresh preview. Preview links are private and expire automatically. Check the client callback against `MCP_OAUTH_REDIRECT_DOMAINS` and `MCP_OAUTH_CUSTOM_SCHEMES`. Also verify that `APP_URL`, `FRONT_URL`, and reverse-proxy forwarding headers reflect the public origins. Confirm that your `/mcp` endpoint is reachable from outside your private network, that your reverse proxy allows streamable HTTP responses, and that no firewall or authentication proxy blocks MCP discovery requests. ## Agent safety behavior * Fetch the current form revision before updating, publishing, or trashing it. * Re-fetch and reconcile the form after a revision conflict. * Ask before publishing or moving a form to the trash. * Use submission data only when it is necessary for the user's request. * Respect validation, workspace permissions, plan cleaning, and rate limits. # Introduction to OpnForm Source: https://docs.opnform.com/introduction An open-source form builder OpnForm is an open-source form builder designed to empower developers and users alike. This technical documentation serves as your comprehensive guide to understanding, implementing, and contributing to OpnForm. For end-user documentation, feature explanations, and usage guides, please refer to our [Help Center](https://help.opnform.com). Self-hosted OpnForm can be used without an Enterprise license for core functionality. Free self-hosted instances include OIDC within a 2-user instance limit. Adding more users and advanced self-hosted features require license activation. See [Self-hosted License](/deployment/self-hosted-license). Managed service with high availability and security. Self-host OpnForm for full control and customization. Join our open-source community and contribute. Integrate OpnForm with our comprehensive API. ## Features * 🚀 No-code form builder with unlimited forms, fields & submissions * 📝 Various input types: Text, Date, URL, Phone, Email, Checkboxes, Select, Multi-Select, Number, Star-ratings, File uploads & more * 🌐 Embed anywhere (website, Notion page, etc.) * 📧 Email notifications for form owners & respondents * 🔒 Hidden fields and form passwords * 🔗 URL form pre-fill * 💬 Slack and Discord integrations * 🪝 Webhooks * 🧠 Form logic and AI-powered form generation * 🎨 Customizable colors, images, and custom code * 🛡️ Captcha form protection * 📅 Form closing date * 🔢 Limit on number of submissions * 📊 Form analytics * 📁 File uploads * 🆔 Unique submission ID * 📱 Single or multi-page forms * 🔍 Form templates for inspiration * 🔄 Editable submissions * 🌐 Custom domain * 🏷️ Remove OpnForm branding * 🔔 Form confirmation emails * ...[and more](https://opnform.com/pricing)! 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. # Tech Stack Source: https://docs.opnform.com/tech-stack Overview of OpnForm's technology stack OpnForm is built using a modern and robust technology stack, ensuring high performance, scalability, and maintainability. Here's an overview of the main technologies used in the project: ## Backend Version 8.0 or higher, powering the core backend functionality. The PHP framework that powers the backend of OpnForm. ## Database Supports relational database management systems like MySQL/MariaDB and PostgreSQL. ## Frontend The progressive JavaScript framework used for building the user interface. The Vue.js framework that enables server-side rendering and other powerful features. A utility-first CSS framework for rapidly building custom user interfaces. ## Asset Compilation Used for running JavaScript tools in the development environment. Package manager for installing and managing frontend dependencies. ## Additional Technologies Containerization platform used for easy deployment and development setup. In-memory data structure store, used for caching and as a message broker. Used for file storage (or compatible alternatives). This tech stack allows OpnForm to deliver a fast, responsive, and feature-rich form building experience. For detailed installation instructions and system requirements, please refer to our [Docker deployment guide](deployment/docker) or [Local deployment guide](deployment/local-deployment).