Skip to main content

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"
}
FieldTypeDescription
widgetNamestringName of the widget that triggered the event
widgetEventstringEvent type — onClick, onChange, onTableLoadData, etc.
formDataobjectAll current form field values at the moment the event fired
widgetValuestringNew value for onChange events; search term for autocomplete
widgetContextstringAdditional context as a JSON string
formCodestringCode of the form being displayed
guidstringRecord UUID or "new" for new records
pluginCodestringUsed to resolve the correct handler class
projectGuidstringProject 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": []
}
FieldTypeDescription
formDataobjectUpdated field values to render in the form
widgetDataarrayRow data for table widgets. Each row must include _id, _sk, and _code.
widgetsStateobjectWidget visibility and read-only state — contains visibility and readOnly dictionaries
fieldAllowedValuesobjectSelect/dropdown options. Key = field name, value = {optionKey: label} dictionary
feCommandarrayFrontend commands to execute (OpenModal, CloseForm, ShowMessage, etc.)
tableMetaobjectPagination metadata for datatable widgets (table events only)
widgetRelatedDataobjectAdditional widget context such as selected row IDs
Required table row fields

Every row in widgetData must include _id, _sk, and _code. Without them row actions (edit, delete, etc.) will not work.

Concurrent requests

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 valueEventHandlerHelper method calledHandler method invoked
onClickRunWidgetOnClickEventForm_onClick, {widgetName}_onClick
onChangeRunFieldOnChangeEvent{widgetName}_onChange
onSaveRunFormOnSaveEventForm_onBeforeSave, Form_onSave, Form_onAfterSave
onTableLoadDataRunWidgetOnTableLoadDataEvent{widgetName}_onTableLoadData → fallback Form_onTableLoadData
onTableEditLoadDataRunWidgetOnTableEditLoadDataEvent{widgetName}_onTableEditLoadData → fallback Form_onTableEditLoadData
onTableRunActionEventRunWidgetOnTableRunActionEventForm_onTableRunActionEvent, {widgetName}_onTableRunActionEvent
onTableRunBatchActionEventRunWidgetOnTableRunBatchActionEventForm_onTableRunBatchActionEvent, {widgetName}_onTableRunBatchActionEvent
onTableCreateRecordEventRunWidgetOnTableCreateRecordEvent{widgetName}_onTableCreateRecordEvent, Form_onTableCreateRecordEvent
onRefreshRunFieldOnRefreshEvent{widgetName}_onRefresh, Form_onRefresh
onDeleteRunFormOnDeleteEventForm_onBeforeDelete, Form_onDelete, Form_onAfterDelete
onCancelRunFormOnCancelEventForm_onCancel
onPrintRunFormOnPrintEventForm_onPrint
onFileUploadRunFormOnFileUploadEventForm_onBeforeFileUpload, Form_onAfterFileUpload
onGetCustomResponseRunOnGetCustomResponseEvent{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
});
}