Skip to main content

Introducing Formengine - The New Formbuilder, try for FREE formengine.io.

How to Connect Forms to Workflow Engine

In this section, we’ll give a high-level overview of how to connect forms that run alongside workflow processes to your application. Before you begin, we strongly recommend reviewing the sample project we provide and reading the detailed description of all form settings in the following sections.

In short, here’s the sequence of steps:

  1. Connect Workflow Engine to your application.
  2. Connect the Forms Plugin to the Workflow Runtime.
  3. Register the Forms Plugin Web API endpoints in ASP.NET, or implement a custom Forms Controller.
  4. If you don’t yet have a controller for the designer API, implement one.
  5. Add the Forms Viewer component to your frontend.
  6. Add the Forms Manager component to your frontend.

Backend​

Recommended ASP.NET setup

If your backend uses ASP.NET and Workflow Engine Web API, register OptimaJet.Workflow.Api.Forms with AddFormsPlugin(...). It attaches the plugin to each Workflow Runtime and maps the standard form retrieval, save, and execute endpoints. See Forms Plugin Web API integration for the complete setup, options, multitenancy behavior, and API reference.

The manual setup below remains useful when you do not use Workflow Engine Web API or need a custom HTTP contract.

Connect the Forms Plugin to the Workflow Runtime​

var formsPluginSettings = new FormsPluginSettings
{
FormsManagerUrl = configuration.Workflow.FormsManagerUrl,
};

var formsPlugin = new FormsPlugin(formsPluginSettings);
WorkflowRuntime runtime = new WorkflowRuntime()
.WithPlugin(formsPlugin)
...

You only need to specify one required parameter, FormsManagerUrl. It has a single purpose: to let the process-scheme designer open the form designer at the right moment. This will be explained in detail in later sections and doesn’t significantly affect the plugin’s behavior.

Implement the Forms Controller​

The Workflow Engine Web API Forms integration provides standard endpoints for all methods described below. Implement the Forms Controller yourself only when frontend-to-backend communication differs from the standard contract or when you need additional application-specific behavior. The sample project demonstrates the recommended standard Web API integration; the custom-controller examples below are intended for applications that need a different HTTP contract.

Forms runtime failures contain a FormsRuntimeErrorCode in SystemError.Code. A custom controller can use it to select an HTTP status consistently with the standard Forms API:

private static int GetStatusCode(SystemError error) => error.Code switch
{
FormsRuntimeErrorCode.FormNotFound or FormsRuntimeErrorCode.CommandNotFound => StatusCodes.Status404NotFound,
FormsRuntimeErrorCode.InvalidFormData or FormsRuntimeErrorCode.FormVersionMismatch => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};

FormDataNotFound, InvalidConfiguration, CommandExecutionFailed, and the backward-compatible Unknown value are server errors. A command that is not associated with the requested form is rejected earlier while the Forms runtime selects forms and commands, and is therefore reported as FormNotFound or CommandNotFound depending on which lookup failed.

You need one method that retrieves a form by its name and version number. It’s straightforward—it just returns the form without tying it to any process. In a multi-tenant application, pass the tenant ID to GetFormParameters so tenant-specific form definitions can be resolved.

[HttpGet]
[Route("form")]
public async Task<ActionResult<Form>> GetForm(
[FromQuery] string formName,
[FromQuery] int? formVersion,
[FromQuery] string? tenantId)
{
GetFormResult formResponse = await _runtime.GetFormsRuntimeApi().GetFormAsync(new GetFormParameters
{
FormKey = new FormKey { FormName = formName, FormVersion = formVersion },
TenantId = tenantId
});

return formResponse.Match<ActionResult>(
ok => Ok(ok.Form),
error => Problem(error.Message, statusCode: GetStatusCode(error))
);
}

Next, you need a method that returns the list of forms available to a specific user within a specific process. Note that you don’t have to read the user ID from the request; most likely you’ll get it from the application context. This code is copied from the sample project, where, for simplicity, there is no authorization or authentication, but there is a user ID that can be switched. If your application exposes tenant IDs in requests, validate that the requested process and user belong to that tenant before returning forms.

[HttpGet]
[Route("get")]
public async Task<ActionResult<ExecutableForm[]>> GetForms(
[FromQuery] Guid processId,
[FromQuery] string user,
[FromQuery] string? tenantId)
{
// Validate processId and user against tenantId here if tenant-specific access is used.

FormsRuntimeApi formsPluginRuntimeApi = _runtime.GetFormsRuntimeApi();
GetExecutableFormsResult executableFormsResponse = await formsPluginRuntimeApi
.GetExecutableFormsAsync(new GetExecutableFormsParameters { ProcessId = processId, IdentityId = user, ConditionCheck = true });

return executableFormsResponse.Match<ActionResult>(
ok => Ok(ok.Forms.Select(f => f with { FormData = f.FormData.ToCamelCase() }).ToArray()),
error => Problem(error.Message, statusCode: GetStatusCode(error))
);
}

Now we need a method that saves the form without advancing it through the workflow.

public record SaveFormApiRequest(FormKey FormKey, Guid ProcessId, string User, Dictionary<string, object?> Data);

[HttpPost]
[Route("save")]
public async Task<ActionResult<object>> SaveForm([FromBody] SaveFormApiRequest request)
{
Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();

SaveFormResult response = await _runtime.GetFormsRuntimeApi().SaveFormAsync(new()
{
FormKey = request.FormKey, ProcessId = request.ProcessId, IdentityId = request.User, FormData = pascalCaseData
});

return response.Match<ActionResult>(
ok => Ok(ok.Data.ToCamelCase()),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: GetStatusCode(error))
);
}

Finally, you need a method that executes a command together with the form.

public record ExecuteFormApiRequest(FormKey FormKey, string CommandName, Guid ProcessId, string User, Dictionary<string, object?> Data);

public record ExecuteFormApiResponse(bool WasExecuted);

[HttpPost]
[Route("execute")]
public async Task<ActionResult<ExecuteFormApiResponse>> ExecuteForm([FromBody] ExecuteFormApiRequest request)
{
Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();

ExecuteFormResult response = await _runtime.GetFormsRuntimeApi()
.ExecuteFormAsync(
new ExecuteFormParameters
{
FormKey = request.FormKey,
CommandName = request.CommandName,
ProcessId = request.ProcessId,
IdentityId = request.User,
FormData = pascalCaseData
});

return response.Match<ActionResult>(
ok => Ok(new ExecuteFormApiResponse(ok.WasExecuted)),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: GetStatusCode(error))
);
}

In general, those are all the essential methods for form interaction in the user-facing application. Keep in mind that we continually convert data from PascalCase to camelCase. You don’t have to do this, but remember that the Form Engine binds data by component keys on the form; if everything coming from your server is in PascalCase, you’ll need to name the keys with an initial capital letter.

Dictionary<string, object?> pascalCaseData = request.Data.ToPascalCase();
...
return response.Match<ActionResult>(
ok => Ok(ok.Data.ToCamelCase()),
validationErrors => BadRequest(validationErrors.Errors.ToCamelCase()),
error => Problem(error.Message, statusCode: GetStatusCode(error))
);

When you use the standard Workflow Engine Web API Forms endpoints, put equivalent boundary conversions in registered IFormsApiFormDataPipelineStep services instead of a custom controller. The integration applies them consistently to requests, responses, and validation errors.

Implement the Designer Controller​

If you already know how to connect the process-scheme designer, then you’ve already implemented this controller, and all the methods for saving forms are in place. If not, read this section.

Frontend​

Connect the Forms Viewer component​

On the client side, in the end-user application, you need to implement a component that renders the form to the user. Important points:

  • showError — a function in your app for displaying errors; you probably already have one.
  • id — the workflow process ID (in this example it matches the document ID and is taken from the URL).
  • selectedUser — in the sample code this value comes from a dropdown of usernames. As mentioned earlier, you might not need the user ID at all if you retrieve it from the backend context.
  • apiUrl — the base URL of your backend application.
  • tenantId — optional tenant ID. FormsViewer passes it to all data handlers through the handler context.
import {Form, FormKey, FormsViewer, FormsViewerRequestContext} from '@optimajet/workflow-forms-viewer'

...

export function UserForm({apiUrl, tenantId}: AppProps) {

const {id} = useParams()
const {selectedUser} = useAppState()
const showError = useShowError()

const getForm = useCallback(async (formKey: FormKey, context?: FormsViewerRequestContext) => {
...
}, [apiUrl])

const getForms = useCallback(async (context?: FormsViewerRequestContext) => {
...
}, [apiUrl, id, selectedUser])

const saveForm = useCallback(async (
processId: string,
formKey: FormKey,
data: Record<string, unknown>,
context?: FormsViewerRequestContext) => {
...
}, [apiUrl, selectedUser])

const executeForm = useCallback(async (
processId: string,
formKey: FormKey,
commandName: string,
data: Record<string, unknown>,
context?: FormsViewerRequestContext) => {
...
}, [apiUrl, selectedUser])

return <FormsViewer getForm={getForm} getForms={getForms} onError={showError} onSuccess={showSuccess} saveForm={saveForm}
executeForm={executeForm} tenantId={tenantId}
/>

}

In short, you need to implement four functions:

  • getForm — calls the controller’s GetForm method described above.
  • getForms — calls the controller’s GetForms method described above.
  • saveForm — calls the controller’s SaveForm method described above.
  • executeForm — calls the controller’s ExecuteForm method described above.

Four functions on the client, four on the server—nothing too complicated. Next, pass these functions to the FormsViewer component from the @optimajet/workflow-forms-viewer library, which you can install by running the command.

npm install @optimajet/workflow-forms-viewer

A sample implementation of the functions is as follows.

Function getForm.

const getForm = useCallback(async (formKey: FormKey, context?: FormsViewerRequestContext) => {
const formVersion = typeof formKey.formVersion === 'number' ? `&formVersion=${formKey.formVersion}` : ''
const tenant = context?.tenantId ? `&tenantId=${encodeURIComponent(context.tenantId)}` : ''
const response = await fetch(`${apiUrl}/reports/forms/form?formName=${formKey.formName}${formVersion}${tenant}`)
if (response.ok) return ((await response.json()) as Form).formCode
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl])

Function getForms.

const getForms = useCallback(async (context?: FormsViewerRequestContext) => {
const tenant = context?.tenantId ? `&tenantId=${encodeURIComponent(context.tenantId)}` : ''
const response = await fetch(`${apiUrl}/reports/forms/get?processId=${id}&user=${selectedUser}${tenant}`)
if (response.ok) return await response.json() as Form[]
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, id, selectedUser])

Function saveForm.

Note that validation errors are returned with an HTTP 400 (Bad Request) status code. You don’t have to handle them this way in every case—you can adjust the implementation as needed.

const saveForm = useCallback(async (
processId: string,
formKey: FormKey,
data: Record<string, unknown>,
context?: FormsViewerRequestContext) => {
const postData = {
formKey: formKey,
processId: processId,
user: selectedUser,
data: data,
tenantId: context?.tenantId
}
const response = await fetch(`${apiUrl}/reports/forms/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(postData)
})
if (response.ok) return {formData: await response.json()}
if (response.status === 400) return {formErrors: await response.json()}
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, selectedUser])

Function executeForm.

Note that validation errors are returned with an HTTP 400 (Bad Request) status code. You don’t have to handle them this way in every case—you can adjust the implementation as needed.

const executeForm = useCallback(async (
processId: string,
formKey: FormKey,
commandName: string,
data: Record<string, unknown>,
context?: FormsViewerRequestContext) => {
const postData = {
formKey: formKey,
processId: processId,
commandName: commandName,
user: selectedUser,
data: data,
tenantId: context?.tenantId
}
const response = await fetch(`${apiUrl}/reports/forms/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(postData)
})
if (response.ok) return {wasExecuted: (await response.json()).wasExecuted}
if (response.status === 400) return {formErrors: await response.json()}
const errorText = await response.text()
throw new Error(errorText || `HTTP error ${response.status}`)
}, [apiUrl, selectedUser])

Connect the Forms Manager component​

On the client side, in the application available to administrators or power users, implement a component that renders the form editor. Important points:

  • showError — a function in your app used to display errors (you likely already have one).
  • licenseKey — the license key for Form Engine (yes, it’s a separate product and requires its own license).
  • apiUrl — the base URL of your backend application.
  • tenantId — optional tenant ID. FormsManager sends it as TenantId with every Forms Plugin designer operation. Without it, shared forms are edited.
import {FormsManager} from '@optimajet/workflow-forms-manager'

export function FormsManagerPage({apiUrl, licenseKey, tenantId}: AppProps) {
const showError = useShowError()
return <FormsManager apiUrl={`${apiUrl}/designer`} licenseKey={licenseKey} tenantId={tenantId} onError={showError}>
</FormsManager>
}

It’s straightforward, but keep in mind that the apiUrl of the FormsManager component must ultimately point to the controller that exposes the workflow-scheme designer API. The FormsManager component lives in the @optimajet/workflow-forms-manager library, which you can install by running the command.

npm install @optimajet/workflow-forms-manager

Once integration is complete, the next section will explore the capabilities of the Forms Plugin.

🔑 Get Trial Key

Alternatively, you can use an AI agent (Claude, Codex, GitHub Copilot, Cursor, or similar) to generate a trial key automatically. Provide the agent with the contents of trial.workflowengine.io/llms.txt and follow the instructions.

Stay in the know
Build Workflow Applications Faster
Star us on GitHub