Required API Endpoints
This page documents every endpoint your backend must expose, with the complete C# implementation from DemoController.cs.
POST /token
FormHostProvider calls this endpoint once when it mounts, before any form is loaded. The response is a short-lived JWT that is attached as a Bearer token to every subsequent request (/loadform, /runevent, and all file endpoints). Your backend validates this token on every incoming request, so no unauthenticated call can reach your form handlers.
The token carries no user identity by default — it is purely a session credential. You can add claims (user ID, roles, tenant) to scope access if your application requires it.
[HttpPost("token")]
public IActionResult GenerateToken()
{
var key = new SymmetricSecurityKey(
System.Text.Encoding.ASCII.GetBytes(_configuration["Jwt:Key"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(60),
signingCredentials: credentials);
return Ok(new Dictionary<string, object?>
{
["token"] = new JwtSecurityTokenHandler().WriteToken(token),
["expiresIn"] = 3600
});
}
POST /loadform
Called once when a form is first displayed — before the user has interacted with anything. The SDK sends the form context (project, plugin code, form code, and record GUID) and receives back everything needed to render the initial state: the form definition, field values, dropdown options, widget visibility, and translations.
The endpoint resolves your handler class, calls its FormInit method, then merges the handler's data on top of the base Buildocs definition. Handler values always win over base values on conflict. If the record GUID is "new", the form opens blank and ready for input; if it is a UUID, the handler is expected to load and return the existing record data.
Request DTO — PluginContextEventRequestDto
public class PluginContextEventRequestDto
{
public string? ProjectGuid { get; set; }
public string? PluginCode { get; set; }
public string? FormCode { get; set; }
public string? Guid { get; set; }
public string? WidgetName { get; set; }
public string? WidgetEvent { get; set; }
public string? WidgetValue { get; set; }
public string? WidgetContext { get; set; }
public Dictionary<string, object>? FormData { get; set; }
public Dictionary<string, object>? Definition { get; set; }
public Dictionary<string, Dictionary<string, string>>? FieldAllowedValues { get; set; }
public WidgetsStateDto? WidgetsState { get; set; }
public Dictionary<string, string>? UiTranslations { get; set; }
// ...
}
Implementation
[HttpPost("loadform")]
public async Task<IActionResult> LoadForm([FromBody] PluginContextEventRequestDto request)
{
HandlerResponse response = new HandlerResponse();
response.Set(HandlerResponse.FormDefinition, request.Definition);
response.Set(HandlerResponse.FieldAllowedValues, request.FieldAllowedValues);
response.Set(HandlerResponse.FormData, request.FormData);
response.Set(HandlerResponse.RequiredFields, request.RequiredFields);
response.Set(HandlerResponse.WidgetsState, request.WidgetsState);
response.Set(HandlerResponse.UiTranslations, request.UiTranslations);
EventHandlerHelper? eventHandlerHelper = null;
try
{
if (request?.Originator != null)
_requestContext.SetOriginator(request.Originator);
PluginHelper pluginHelper = CreatePluginHelper(request);
Type? t = await PluginHelper.GetHandlerType(request.PluginCode, request.FormCode);
object? objInstance = await pluginHelper.GetHandlerInstance(t, request);
if (objInstance != null && t != null)
{
eventHandlerHelper = new EventHandlerHelper(t, objInstance);
await eventHandlerHelper.SetContext(request);
Screen? screen = await eventHandlerHelper.GetScreen();
screen?.SetRequest(request);
screen?.SetWidgetsVisibility(request.WidgetsState?.Visibility);
screen?.SetWidgetsReadonly(request.WidgetsState?.Readonly);
await eventHandlerHelper.RunFormInit(true);
// Handler fieldAllowedValues overrides Buildocs base; fall back to request values.
var handlerAllowedValues = await eventHandlerHelper.GetFieldAllowedValues()
as Dictionary<string, Dictionary<string, string>>;
if (handlerAllowedValues?.Count > 0)
response.Set(HandlerResponse.FieldAllowedValues, handlerAllowedValues);
// Handler data merged onto Buildocs base; handler wins on conflict.
var handlerData = await eventHandlerHelper.GetFormData()
is Dictionary<string, object> hd ? hd : null;
var mergedData = new Dictionary<string, object>(request.FormData ?? new());
if (handlerData != null)
foreach (var kv in handlerData) mergedData[kv.Key] = kv.Value;
response.Set(HandlerResponse.FormData, mergedData);
response.Set(HandlerResponse.Layout, await eventHandlerHelper.GetRecordLayout());
if (screen?.HasModifiedState() == true)
{
screen.SetPluginHelper(pluginHelper);
response.Set(HandlerResponse.FormDefinition,
await screen.GetUpdatedFormDefinition() ?? request.Definition);
response.Set(HandlerResponse.WidgetsState, new
{
visibility = screen.GetChangedWidgetsVisibility(),
readOnly = screen.GetChangedWidgetsReadonly()
});
}
else
{
response.Set(HandlerResponse.FormDefinition,
await pluginHelper.GetFormDefinition() ?? request.Definition);
}
}
}
catch (RecordNotFoundException ex)
{
_requestContext.GetFECommandProvider().SetMessage(MessageTypes.Warning, ex.Message);
_requestContext.GetFECommandProvider().Event("CloseForm");
}
catch (Exception ex) when (ex is not ActionTerminationException)
{
// surface warning to user
}
var feCommands = eventHandlerHelper != null
? await eventHandlerHelper.GetCommands() : null;
response.Set(HandlerResponse.FECommand, feCommands);
return Ok(response.Get());
}
POST /runevent
/runevent is the central endpoint of every Buildocs integration. The SDK calls it automatically every time anything happens on screen — a button is clicked, a field value changes, a select box refreshes, a table loads its rows, a row action fires, a modal opens, and so on. There is no client-side routing: all events flow through this single endpoint, and your handler code decides what each event means.
Request structure
When a form event occurs the SDK POSTs a JSON payload to /runevent:
{
"widgetName": "submitbtn",
"widgetEvent": "onClick",
"formData": {
"customerName": "John Doe",
"email": "john@example.com",
"phone": "+1234567890"
},
"widgetContext": "{\"additionalData\":\"value\"}",
"formCode": "CUSTOMERFORM",
"guid": "abc123-def456-ghi789",
"pluginCode": "NONE",
"projectGuid": "proj-123"
}
| Field | Type | Description |
|---|---|---|
widgetName | string | Name of the widget that triggered the event |
widgetEvent | string | Event type — onClick, onChange, onTableLoadData, etc. |
formData | object | All current form field values at the moment the event fired |
widgetValue | string | New value for onChange events; search term for autocomplete |
widgetContext | string | Additional context as a JSON string |
formCode | string | Code of the form being displayed |
guid | string | Record UUID or "new" for new records |
pluginCode | string | Used to resolve the correct handler class |
projectGuid | string | Project GUID |
For datatable events, the payload also includes pagination metadata:
{
"DataTableMeta": {
"serverPaginationEnabled": true,
"rowsPerPage": 50,
"pageIndex": 0,
"nextToken": "",
"previousToken": "",
"paginationDirection": "first",
"rowCount": 0
}
}
Response structure
Return a plain JSON object — no special wrapper class required:
{
"formData": {},
"widgetData": [],
"widgetsState": {
"visibility": {},
"readOnly": {}
},
"fieldAllowedValues": {},
"feCommand": []
}
| Field | Type | Description |
|---|---|---|
formData | object | Updated field values to render in the form |
widgetData | array | Row data for table widgets. Each row must include _id, _sk, and _code. |
widgetsState | object | Widget visibility and read-only state — contains visibility and readOnly dictionaries |
fieldAllowedValues | object | Select/dropdown options. Key = field name, value = {optionKey: label} dictionary |
feCommand | array | Frontend commands to execute (OpenModal, CloseForm, ShowMessage, etc.) |
tableMeta | object | Pagination metadata for datatable widgets (table events only) |
widgetRelatedData | object | Additional widget context such as selected row IDs |
Every row in widgetData must include _id, _sk, and _code. Without them row actions (edit, delete, etc.) will not work.
When a form loads, multiple widgets fire their events simultaneously — a form with two datatables and a dropdown will send three parallel /runevent requests. Make your handlers stateless and ensure database operations are thread-safe.
How handler resolution works (reflection)
The controller does not know at compile time which handler class to invoke — forms are registered at runtime, and new forms can be added without changing the controller. Handler resolution is therefore done entirely through reflection:
Step 1 — locate the handler type
Type? t = await PluginHelper.GetHandlerType(request.PluginCode, request.FormCode);
PluginHelper.GetHandlerType scans the loaded assemblies at runtime for a class that is associated with the given PluginCode + FormCode combination. The match is done by inspecting class attributes or naming conventions — no static registry is maintained. The returned Type object is the handler class for this form.
Step 2 — instantiate the handler
object? objInstance = await pluginHelper.GetHandlerInstance(t, request);
GetHandlerInstance creates an instance of the handler class via reflection (Activator.CreateInstance or a DI-aware factory). Dependencies declared in the handler's constructor are resolved from the service container at this point.
Step 3 — wrap in EventHandlerHelper
eventHandlerHelper = new EventHandlerHelper(t, objInstance);
await eventHandlerHelper.SetContext(request);
EventHandlerHelper holds a reference to both the Type and the live instance. All subsequent method calls on the handler go through this helper, which uses reflection to find and invoke the correct method by name.
Step 4 — dispatch by event type
string? baseEvent = request.WidgetEvent?.ToUpper();
if (baseEvent == EventHandlerEvents.OnChange)
await eventHandlerHelper.RunFieldOnChangeEvent(
request.WidgetEvent!, request.WidgetName!, request.WidgetValue);
else if (baseEvent == EventHandlerEvents.OnClick)
await eventHandlerHelper.RunWidgetOnClickEvent(
request.WidgetEvent!, request.WidgetName!);
Each Run* method on EventHandlerHelper uses reflection to locate the matching method on your handler class — for example, RunWidgetOnClickEvent looks for a method named OnClick and calls it with (widgetName, context). If your handler does not declare that method, the call is a no-op. This means you only implement the methods your form actually needs.
The full dispatch table:
widgetEvent value | EventHandlerHelper method called | Handler method invoked |
|---|---|---|
onClick | RunWidgetOnClickEvent | Form_onClick, {widgetName}_onClick |
onChange | RunFieldOnChangeEvent | {widgetName}_onChange |
onSave | RunFormOnSaveEvent | Form_onBeforeSave, Form_onSave, Form_onAfterSave |
onTableLoadData | RunWidgetOnTableLoadDataEvent | {widgetName}_onTableLoadData → fallback Form_onTableLoadData |
onTableEditLoadData | RunWidgetOnTableEditLoadDataEvent | {widgetName}_onTableEditLoadData → fallback Form_onTableEditLoadData |
onTableRunActionEvent | RunWidgetOnTableRunActionEvent | Form_onTableRunActionEvent, {widgetName}_onTableRunActionEvent |
onTableRunBatchActionEvent | RunWidgetOnTableRunBatchActionEvent | Form_onTableRunBatchActionEvent, {widgetName}_onTableRunBatchActionEvent |
onTableCreateRecordEvent | RunWidgetOnTableCreateRecordEvent | {widgetName}_onTableCreateRecordEvent, Form_onTableCreateRecordEvent |
onRefresh | RunFieldOnRefreshEvent | {widgetName}_onRefresh, Form_onRefresh |
onDelete | RunFormOnDeleteEvent | Form_onBeforeDelete, Form_onDelete, Form_onAfterDelete |
onCancel | RunFormOnCancelEvent | Form_onCancel |
onPrint | RunFormOnPrintEvent | Form_onPrint |
onFileUpload | RunFormOnFileUploadEvent | Form_onBeforeFileUpload, Form_onAfterFileUpload |
onGetCustomResponse | RunOnGetCustomResponseEvent | {widgetName}_{methodName} |
RunFormInit is always called first (with isInitialLoad: false) regardless of event type, so shared initialization logic in Form_onInit runs on every request.
Request DTO — PluginContextEventRequestDto
public class PluginContextEventRequestDto
{
public string? ProjectGuid { get; set; }
public string? PluginCode { get; set; }
public string? FormCode { get; set; }
public string? Guid { get; set; }
public string? WidgetName { get; set; }
public string? WidgetEvent { get; set; }
public string? WidgetValue { get; set; }
public string? WidgetContext { get; set; }
public Dictionary<string, object>? FormData { get; set; }
public Dictionary<string, object>? Definition { get; set; }
public Dictionary<string, Dictionary<string, string>>? FieldAllowedValues { get; set; }
public WidgetsStateDto? WidgetsState { get; set; }
public Dictionary<string, string>? UiTranslations { get; set; }
// ...
}
Implementation
[HttpPost("runevent")]
public virtual async Task<IActionResult> RunEvent(
[FromBody] PluginContextEventRequestDto request)
{
var response = new HandlerResponse();
// ... initialize response fields ...
EventHandlerHelper? eventHandlerHelper = null;
try
{
PluginHelper pluginHelper = CreatePluginHelper(request);
Type? t = await PluginHelper.GetHandlerType(request.PluginCode, request.FormCode);
object? objInstance = await pluginHelper.GetHandlerInstance(t, request);
if (objInstance != null && t != null)
{
eventHandlerHelper = new EventHandlerHelper(t, objInstance);
await eventHandlerHelper.SetContext(request);
await eventHandlerHelper.RunFormInit(false);
string? baseEvent = request.WidgetEvent?.ToUpper();
if (baseEvent == EventHandlerEvents.OnChange)
await eventHandlerHelper.RunFieldOnChangeEvent(
request.WidgetEvent!, request.WidgetName!, request.WidgetValue);
else if (baseEvent == EventHandlerEvents.OnClick)
await eventHandlerHelper.RunWidgetOnClickEvent(
request.WidgetEvent!, request.WidgetName!);
else if (baseEvent == EventHandlerEvents.OnSave)
await eventHandlerHelper.RunFormOnSaveEvent(
request.WidgetEvent!, request.WidgetName!);
else if (baseEvent == EventHandlerEvents.OnTableLoadData)
response.Set(HandlerResponse.WidgetData,
await eventHandlerHelper.RunWidgetOnTableLoadDataEvent(
request.WidgetEvent!, request.WidgetName!));
// ... additional event types ...
response.Set(HandlerResponse.FECommand,
await eventHandlerHelper.GetCommands());
response.Set(HandlerResponse.FieldAllowedValues,
await eventHandlerHelper.GetFieldAllowedValues());
response.Set(HandlerResponse.FormData,
await eventHandlerHelper.GetFormData());
}
}
catch (ActionTerminationException)
{
if (eventHandlerHelper != null)
response.Set(HandlerResponse.FECommand,
await eventHandlerHelper.GetCommands());
}
return Ok(response.Get());
}
POST /uploadfiles
Handles file uploads from file-uploader widgets. Because the payload is a binary file, not JSON, the request arrives as multipart/form-data. The form context fields (pluginCode, formCode, guid, WidgetName, WidgetEvent, formData) are encoded as individual form fields alongside the file payload. The endpoint reconstructs the PluginContextEventRequestDto manually from those fields, then resolves and calls your handler in the same way as /runevent. After the file is stored, the updated list of all files linked to the record is returned so the SDK can refresh the file-uploader widget.
[HttpPost("uploadfiles")]
[Consumes("multipart/form-data")]
[DisableRequestSizeLimit]
[RequestFormLimits(ValueLengthLimit = int.MaxValue, MultipartBodyLengthLimit = long.MaxValue)]
public async Task<IActionResult> UploadFiles()
{
// Deserialize request context from form fields
PluginContextEventRequestDto request = new PluginContextEventRequestDto
{
PluginCode = Request.Form["pluginCode"].ToString(),
FormCode = Request.Form["formCode"].ToString(),
Guid = Request.Form["guid"].ToString(),
WidgetName = Request.Form["WidgetName"].ToString(),
WidgetEvent = Request.Form["WidgetEvent"].ToString(),
FormData = JsonConvert.DeserializeObject<Dictionary<string, object>>(
Request.Form["formData"].ToString())
};
PluginHelper pluginHelper = CreatePluginHelper(request);
Type? t = await PluginHelper.GetHandlerType(request.PluginCode, request.FormCode);
object? objInstance = await pluginHelper.GetHandlerInstance(t, request);
if (objInstance != null && t != null)
{
EventHandlerHelper eventHandlerHelper = new EventHandlerHelper(t, objInstance);
await eventHandlerHelper.SetContext(request);
await eventHandlerHelper.RunFormInit(false);
await eventHandlerHelper.RunFormBeforeFileUploadEvent(
request.WidgetEvent!, request.WidgetName!, Request.Form.Files);
DataRecord dataRecord = (DataRecord)(await eventHandlerHelper.GetRecord())!;
var (newFiles, allFiles) = await DoUploadFiles(dataRecord, request.FormData);
await eventHandlerHelper.RunFormAfterFileUploadEvent(
request.WidgetEvent!, request.WidgetName!, newFiles);
response.Set(HandlerResponse.LinkedFiles, allFiles);
response.Set(HandlerResponse.FECommand,
await eventHandlerHelper.GetCommands());
}
return Ok(response.Get());
}
POST /deletefile
Called when the user removes a file from a file-uploader widget. The endpoint receives the FileId (a "bucket-name:path/to/file" string), deletes the object from storage, then loads the record, strips the deleted file from its linked-files list, and returns the updated list so the SDK can immediately reflect the removal in the UI.
Request DTO — FileRequestDto
public class FileRequestDto
{
public string? PluginCode { get; set; }
public string? FormCode { get; set; }
public string? Guid { get; set; }
public string? FieldName { get; set; }
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("deletefile")]
public async Task<IActionResult> DeleteFile([FromBody] FileRequestDto request)
{
string[] parts = request.FileId!.Split(':', 2);
string bucketName = parts[0];
string filePath = parts[1];
if (await _fileStorage.DeleteFileAsync(filePath, bucketName))
{
// load record, remove file from list, save
...
response.Set(HandlerResponse.LinkedFiles, updatedList);
}
response.Set(HandlerResponse.LinkedFileDeleteSuccess, result);
return Ok(response.Get());
}
POST /downloadfile
Streams a stored file directly to the browser as a binary attachment. The Content-Disposition: attachment header tells the browser to trigger a Save dialog rather than attempting to render the file inline. The file name is taken from the path component of FileId and UTF-8 encoded in the header so non-ASCII characters are preserved correctly.
Request DTO — FileRequestDto
public class FileRequestDto
{
public string? PluginCode { get; set; }
public string? FormCode { get; set; }
public string? Guid { get; set; }
public string? FieldName { get; set; }
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("downloadfile")]
public async Task<IActionResult> DownloadFile([FromBody] FileRequestDto request)
{
string[] parts = request.FileId!.Split(':', 2);
Stream? fileStream = await _fileStorage.GetFileStreamAsync(parts[1], parts[0]);
Response.ContentType = _fileStorage.GetMimeType(Path.GetFileName(parts[1]));
Response.Headers.Append("Content-Disposition",
$"attachment; filename*=UTF-8''{Uri.EscapeDataString(Path.GetFileName(parts[1]))}");
await _fileStorage.WriteInChunksAsync(Response.Body, fileStream!);
return new EmptyResult();
}
POST /getpresignedurl
Generates a short-lived, pre-authenticated URL that gives the browser direct access to the stored object — without routing the bytes through your server. This is used when the frontend needs to display or stream a file (e.g. showing an image inline or opening a PDF in a new tab) and you want to avoid the overhead of proxying the content. The URL is typically valid for a few minutes and is signed by your storage provider (e.g. AWS S3).
Request DTO — LinkedFileRequestDto
public class LinkedFileRequestDto
{
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("getpresignedurl")]
public async Task<IActionResult> GetPresignedUrl([FromBody] LinkedFileRequestDto request)
{
string[] parts = request.FileId!.Split(':', 2);
string url = await _fileStorage.GetPresignedUrlAsync(parts[1], parts[0]);
return Ok(new Dictionary<string, object?> { ["presignedUrl"] = url });
}
POST /getlinkedfilebychunks
Serves a specific byte range of a stored file using HTTP 206 Partial Content. In-browser PDF viewers (such as PDF.js) and HTML5 video players do not download the entire file before rendering — they request only the byte range they need at that moment using the Range request header. This endpoint reads that header, fetches the requested slice from storage, and responds with the correct Content-Range header so the browser can assemble the full file progressively.
Request DTO — LinkedFileRequestDto
public class LinkedFileRequestDto
{
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("getlinkedfilebychunks")]
public async Task<IActionResult> GetLinkedFileByChunks(
[FromBody] LinkedFileRequestDto request)
{
// Parse Range header: "bytes=0-81919"
string rangeHeader = Request.Headers["Range"].ToString();
// ... parse start/end, validate against file size ...
Response.StatusCode = 206;
Response.Headers.Append("Accept-Ranges", "bytes");
Response.Headers.Append("Content-Range", $"bytes {start}-{end}/{fileSize}");
return File(chunkBytes, mimeType);
}
POST /getlinkedfilemeta
Returns metadata about a stored file — name, size, and MIME type — without transferring the file content. The file-viewer widget calls this first to decide how to display the file (which renderer to use, whether to show a size warning, etc.) before making a separate request for the actual bytes.
Request DTO — LinkedFileRequestDto
public class LinkedFileRequestDto
{
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("getlinkedfilemeta")]
public async Task<IActionResult> GetLinkedFileMeta(
[FromBody] LinkedFileRequestDto request)
{
string[] parts = request.FileId!.Split(':', 2);
var meta = await _fileStorage.GetFileMetadataAsync(parts[1], parts[0]);
string mimeType = _fileStorage.GetMimeType(Path.GetFileName(parts[1]));
return Ok(new Dictionary<string, object?>
{
["linkedFileName"] = Path.GetFileName(parts[1]),
["linkedFileSize"] = meta?.ContentLength ?? 0,
["mimeType"] = mimeType,
["linkedFileMimeType"] = mimeType
});
}
POST /getlinkedfile
Returns the complete file content encoded as a base64 string. This is used for smaller files that the frontend needs to embed directly in the page — for example rendering an image from a data: URI or passing file bytes to a JavaScript library. For large files, prefer /getpresignedurl or /getlinkedfilebychunks to avoid loading the entire file into memory on the server.
Request DTO — LinkedFileRequestDto
public class LinkedFileRequestDto
{
public string? FileId { get; set; } // format: "bucket-name:path/to/file"
}
Implementation
[HttpPost("getlinkedfile")]
public async Task<IActionResult> GetLinkedFile(
[FromBody] LinkedFileRequestDto request)
{
string[] parts = request.FileId!.Split(':', 2);
byte[]? fileData = await _fileStorage.GetFileBytesAsync(parts[1], parts[0]);
return Ok(new Dictionary<string, object?>
{
["linkedFile"] = fileData != null ? Convert.ToBase64String(fileData) : null,
["linkedFileExt"] = Path.GetExtension(parts[1]).TrimStart('.'),
["linkedFileSize"] = fileData?.Length
});
}