Low-Code & Pro-Code working together: Generate a CSV file Using Power Automate and Custom APIs
I would like to start by saying that the power of low-code is growing with each Power Platform release wave, but I also have to say that the power of low-code combined with pro-code solutions is transforming and I believe this is how we address complex business needs.
In my recent project within the finance industry, I was tasked with designing a flexible, scalable data model that included key entities like customers (I’ve reused account table for this), contracts, investments and some other tables. One requirement stood out: “users needed a simple button that, when clicked, would pull all the records from a specific view and generate a consolidated CSV file”.
The user provided an additional description of the requirement: “The number and name of the columns have to be always the same”. I have to say that this last part made the requirement easier, but I was still thinking on the performance side. As you already suspect I was thinking about using Power Automate to generate the CSV file, but would Power Automate be able to handle thousands of records?.
As you already know, the performance of Power Automate is not the best, so I thought about combining low-code with pro-code by using a custom API within the cloud flow. The Custom API would take care of generating and updating the CSV file. My goal with this approach was to make the cloud flow much faster by delegating all the heavy lifting to the custom API.
But I had one last point to take into account, the two minutes of execution time that a Custom API has (just like the pro-code plugins). So I couldn’t retrieve thousands of records and send them all to the Custom API because it could take more than 2 minutes and then get a timeout error. That’s why the low-code is so important in these cases to overcome some limitations of the pro-code.
The Power Automate cloud flow will send batches of 250 records to the Custom API and the Custom API will create and update the CSV file with the next batch. With this I managed to make my solution robust enough not to fall under the two-minute runtime limitation.
Without further introduction let’s start.
JavaScript code for the button
I will not explain on how to add a button because you can use either use the new command designer of the classic ribbon workbench to do that and there are a lot of Youtube videos that show how to do it, here is the code you can execute within the button in order to call a Power Automate Cloud flow, your button should call the GenerateCSVFile function:
export async function GenerateCSVFile() {
console.log(`Function GenerateCSVFile Triggered`);
try {
const alertStrings: Xrm.Navigation.AlertStrings = {
title: “CSV File Generation”,
text: “The file will be generated in the background, you will get an email to let you know when the process is done.”
};
await Xrm.Navigation.openAlertDialog(alertStrings).catch((e: any) => console.log(‘Error:’, e.message));
const userEmail = await getCurrentUserEmail();
console.log(`userEmail: ${userEmail}`);
const flowUrlcoll = await GetenvironmentVariableValue(“generatecsvfile”);
const flowUrl = flowUrlcoll.entities[0].value;
triggerPowerAutomateFlow(flowUrl, userEmail)
.then(response => {
if (‘data’ in response) {
// The response is of type FlowResponse
console.log(“Flow triggered successfully. Status Code:”, response.status);
console.log(“Response Data:”, response.data);
} else {
// The response is of type FlowError
console.warn(“Flow did not execute successfully. Status Code:”, response.status);
console.warn(“Error Message:”, response.errorMessage);
if (response.errorDetails) {
console.warn(“Error Details:”, response.errorDetails);
}
}
})
.catch(error => {
console.error(“Failed to trigger flow:”, error);
if (error.errorDetails) {
console.error(“Error Details:”, error.errorDetails);
}
});
}
catch (error) {
console.log(`GenerateCSVFile error`, error);
}
finally {
console.log(`GenerateCSVFile finally`);
}
}
export async function getCurrentUserEmail(): Promise<string | null> {
try {
// Get the current user ID
const userId = Xrm.Utility.getGlobalContext().userSettings.userId;
// The userId contains curly braces, so remove them
const cleanedUserId = userId.replace(“{“, “”).replace(“}”, “”);
// Make the API call
const result = await Xrm.WebApi.retrieveRecord(“systemuser”, cleanedUserId, “?$select=internalemailaddress”);
const userEmail = result[“internalemailaddress”];
return userEmail ? userEmail : null; // Return the email or null if not found
} catch (error: any) {
console.error(“Error retrieving user email: “, error.message);
return null; // Return null in case of an error
}
}
export async function GetenvironmentVariableValue(schemaname: string): Promise<any> {
return new Promise<Xrm.RetrieveMultipleResult>(function (resolve, reject) {
if (schemaname) {
const fetchXml = `
<fetch top=”1″>
<entity name=”environmentvariablevalue”>
<attribute name=”value” />
<link-entity name=”environmentvariabledefinition” from=”environmentvariabledefinitionid” to=”environmentvariabledefinitionid” alias=”def”>
<filter>
<condition attribute=”schemaname” operator=”eq” value=”${schemaname}” />
</filter>
</link-entity>
</entity>
</fetch>`;
getDatabyFetchXML(“environmentvariablevalue”, fetchXml).then(
function success(results) {
resolve(results);
},
function (error) {
reject(error);
}
);
}
else {
reject(null);
}
});
}
export async function triggerPowerAutomateFlow(flowUrl: string, inputParameter: any): Promise<Interfaces.FlowResponse | Interfaces.FlowError> {
// Prepare the payload to send to the Power Automate Cloud flow
const payload: any = {
useremail: inputParameter
};
try {
// Perform the HTTP POST request
const response = await fetch(flowUrl, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’,
},
body: JSON.stringify(payload)
});
if (!response.ok) {
// Attempt to parse error details from the response
let errorDetails = null;
try {
errorDetails = await response.json();
} catch (e) {
console.warn(“Failed to parse error details from response.”);
}
throw {
status: response.status,
statusText: response.statusText,
errorMessage: “Failed to trigger flow”,
errorDetails: errorDetails
} as FlowError;
}
// Parse the response JSON data
const data = await response.json();
// Return the complete response
return {
status: response.status,
headers: response.headers,
data: data
};
} catch (error: any) {
console.error(`error:`, error);
throw error;
}
}
export interface FlowError {
status: number;
statusText: string;
errorMessage: string;
errorDetails?: any;
}
Since I am calling a cloud flow using JavaScript code making an HTTP request, I am using environment variables to store the cloud flow URL, so in the above code I am using a FetchXML and the schema name of the environment variable to retrieve the URL of the cloud flow.
Custom API
I will not explain either how to create the Custom API because you can find it in Microsoft documentation or even in YouTube videos, but here is the code for the Custom Api to generate the CSV file in SharePoint:
using Microsoft.Xrm.Sdk;
using System;
namespace Main.CustomAPIs
{
public class GenerateCSVFile : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
ITracingService tracingService = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
IPluginExecutionContext context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
IOrganizationServiceFactory factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
IOrganizationService service = factory.CreateOrganizationService(context.UserId);
tracingService.Trace(“Start GenerateCSVFile Custom API”);
string GenerateCSVFile_batchbody = (string)context.InputParameters[“GenerateCSVFile_batchbody”];
string GenerateCSVFile_tenantId = (string)context.InputParameters[“GenerateCSVFile_tenantId”];
string GenerateCSVFile_clientId = (string)context.InputParameters[“GenerateCSVFile_clientId”];
string GenerateCSVFile_clientSecret = (string)context.InputParameters[“GenerateCSVFile_clientSecret”];
string GenerateCSVFile_siteId = (string)context.InputParameters[“GenerateCSVFile_siteId”];
string GenerateCSVFile_libraryName = (string)context.InputParameters[“GenerateCSVFile_libraryName”];
string GenerateCSVFile_uploadFolderPath = (string)context.InputParameters[“GenerateCSVFile_uploadFolderPath”];
string GenerateCSVFile_FileName = (string)context.InputParameters[“GenerateCSVFile_FileName”];
var invHandler = new HandlerClasss(tracingService, service);
invHandler.ProcessCustomTableJsonAsync(GenerateCSVFile_batchbody, GenerateCSVFile_tenantId, GenerateCSVFile_clientId, GenerateCSVFile_clientSecret, GenerateCSVFile_siteId, GenerateCSVFile_libraryName, GenerateCSVFile_uploadFolderPath, GenerateCSVFile_FileName, context).Wait();
tracingService.Trace(“End GenerateCSVFile Custom API”);
}
}
}
In the code above you can see that I am using a service principal (Client Id, Secret Value) to connect to SharePoint to upload the CSV file in there. Therefore, in the next part of this blogpost I will use environment variables within the cloud flow to pass these values as input parameters to the Custom API.
For now let’s focus on finishing the Custom API code.
Handler class
In the previous code you can see I’m creating first a handler class called invHandler and then calling a method from that class, here is the code for the handler class:
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using Core.Helpers;
using System.Globalization;
using System.Threading.Tasks;
public class HandlerClasss
{
private readonly ITracingService _tracingService;
private readonly IOrganizationService _service;
public HandlerClasss(ITracingService tracingService, IOrganizationService service)
{
_tracingService = tracingService;
_service = service;
}
public async Task ProcessCustomTableJsonAsync(string jsonString, string tenantId, string clientId, string clientSecret, string siteId, string libraryName, string uploadFolderPath, string fileName, IPluginExecutionContext context)
{
_tracingService.Trace(“Start ProcessCustomTableJson”);
try
{
// Parse the input JSON string
JArray CustomTables = JArray.Parse(jsonString);
// Loop through each CustomTable object and make changes
foreach (JObject CustomTable in CustomTables)
{
// Get a child related record
Entity childRelatedRecord = getLastChildRelatedRecord(CustomTable);
// Rename columns
RenameProperty(CustomTable, “name”, “INVNR”);
RenameProperty(CustomTable, “serialnumber”, “CONEXT”);
RenameProperty(CustomTable, “contract.lessornumber”, “NR LO”);
RenameProperty(CustomTable, “account.accountnumber”, “NRREL”);
RenameProperty(CustomTable, “contract.area”, “AREA”);
// Add new columns
CustomTable[“NRCN”] = CustomTable[“contract.name”].ToString().Substring(3); // Subtract first 3 chars from INVNR
CustomTable[“DLVVD1”] = DateTime.UtcNow.AddDays(19).ToString(“dd-MMM-yyyy”, CultureInfo.InvariantCulture); // Current date + 19 days
CustomTable[“DLVVD2”] = childRelatedRecord.Id != Guid.Empty ? childRelatedRecord.GetAttributeValue<DateTime>(“zvbegin”).ToString(“dd-MMM-yyyy”, CultureInfo.InvariantCulture) : “”;
CustomTable[“DTFROM”] = childRelatedRecord.Id != Guid.Empty ? childRelatedRecord.GetAttributeValue<DateTime>(“zvbegin”).ToString(“dd-MMM-yyyy”, CultureInfo.InvariantCulture) : “”;
CustomTable[“DTTO”] = childRelatedRecord.Id != Guid.Empty ? childRelatedRecord.GetAttributeValue<DateTime>(“cwserbegin”).ToString(“dd-MMM-yyyy”, CultureInfo.InvariantCulture) : “”;
// Handle conditional PAY-METH
CustomTable[“PAY-METH”] = CustomTable[“contract.paymentmethod”]?.ToString() == “Lastschrift” ? “1” : “0”;
// Handle Kundennummer
if (CustomTable[“NRREL”] != null && CustomTable[“NR LO”] != null)
{
long accountNumber = long.Parse(CustomTable[“NRREL”].ToString());
long lessorNumber = long.Parse(CustomTable[“NR LO”].ToString());
CustomTable[“Kundennummer Kurz”] = (accountNumber – (lessorNumber * 10000000)).ToString();
}
CustomTable[“ZV_BEGINN”] = childRelatedRecord.Id != Guid.Empty ? childRelatedRecord.GetAttributeValue<DateTime>(“zvbegin”).ToString(“dd-MMM-yyyy”, CultureInfo.InvariantCulture) : “”;
}
var sharePointHandler = new SharePointHandler(_tracingService);
await sharePointHandler.UploadCsvFileAsync(CustomTables, tenantId, clientId, clientSecret, siteId, libraryName, uploadFolderPath, fileName);
context.OutputParameters[“CustomTableGenerateCSVFile_message”] = “ok”;
}
catch (Exception ex)
{
context.OutputParameters[“CustomTableGenerateCSVFile_message”] = ex.Message;
}
_tracingService.Trace(“End ProcessCustomTableJson”);
}
public async Task UploadCsvFileAsync(JArray modifiedJson, string tenantId, string clientId, string clientSecret, string siteId, string libraryName, string uploadFolderPath, string fileName)
{
_tracingService.Trace(“Start UploadCsvFileAsync”);
// Ensure graphClient is declared here and is available in the current context
GraphServiceClient graphClient;
try
{
// Authenticate using Azure Identity
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
graphClient = new GraphServiceClient(credential);
// Step 1: Generate CSV from modified JSON
byte[] newFileBytes = GenerateCsvFromJson(modifiedJson);
// Step 2: Get the SharePoint document library (drive)
var drives = await graphClient.Sites[siteId].Drives.GetAsync();
var drive = drives.Value.FirstOrDefault(d => d.Name.Equals(libraryName, StringComparison.OrdinalIgnoreCase));
if (drive == null)
{
throw new Exception($”Document library ‘{libraryName}’ not found.”);
}
byte[] existingFileBytes = null;
bool fileExists = false;
// Step 3: List items in the folder and check if the file exists
try
{
var folderItems = await graphClient.Drives[drive.Id]
.Root
.ItemWithPath(uploadFolderPath)
.Children
.GetAsync();
// Check if the file exists in the folder
fileExists = folderItems.Value.Any(item => item.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase));
}
catch (ServiceException ex)
{
_tracingService.Trace(“Error retrieving folder items: “ + ex.Message);
throw new InvalidPluginExecutionException(“An error occurred while retrieving folder items.”);
}
// Step 4: If the file exists, download its content
if (fileExists)
{
try
{
var existingStream = await graphClient.Drives[drive.Id]
.Root
.ItemWithPath($”{uploadFolderPath}/{fileName}”)
.Content
.GetAsync();
using (var memoryStream = new MemoryStream())
{
existingStream.CopyTo(memoryStream);
existingFileBytes = memoryStream.ToArray();
}
}
catch (Exception ex)
{
_tracingService.Trace($”Error downloading the file content: {ex.Message}”);
throw;
}
}
// Step 5: Combine the existing content with the new content if file exists
byte[] combinedFileBytes;
if (existingFileBytes != null)
{
combinedFileBytes = CombineCsvFiles(existingFileBytes, newFileBytes);
}
else
{
// If the file doesn’t exist, use the new CSV content directly
combinedFileBytes = newFileBytes;
}
// Step 6: Upload the combined CSV file back to SharePoint
using (var uploadStream = new MemoryStream(combinedFileBytes))
{
await graphClient.Drives[drive.Id]
.Root
.ItemWithPath($”{uploadFolderPath}/{fileName}”)
.Content
.PutAsync(uploadStream);
}
Console.WriteLine(“File uploaded successfully.”);
}
catch (Exception ex)
{
_tracingService.Trace(“Error in UploadCsvFileAsync: “ + ex.Message);
throw new InvalidPluginExecutionException(“An error occurred in UploadCsvFileAsync: “ + ex.Message);
}
}
// Helper method to convert modified JSON to CSV
private byte[] GenerateCsvFromJson(JArray modifiedJson)
{
var csvBuilder = new StringBuilder();
// Add header (assuming each object has the same keys)
var headers = modifiedJson.FirstOrDefault()?.ToObject<JObject>()?.Properties().Select(p => p.Name).ToArray();
if (headers != null)
{
csvBuilder.AppendLine(string.Join(“,”, headers));
}
// Add data rows
foreach (JObject obj in modifiedJson)
{
var values = obj.Properties().Select(p => p.Value.ToString()).ToArray();
csvBuilder.AppendLine(string.Join(“,”, values));
}
// Convert CSV string to bytes
return Encoding.UTF8.GetBytes(csvBuilder.ToString());
}
// Helper method to combine two CSV byte arrays (existing content + new content)
private byte[] CombineCsvFiles(byte[] existingFile, byte[] newFile)
{
using (var memoryStream = new MemoryStream())
{
// Write the existing file content to memoryStream
memoryStream.Write(existingFile, 0, existingFile.Length);
// Skip the headers in the new file (assuming the first row is headers)
string newCsvContent = Encoding.UTF8.GetString(newFile);
var newCsvLines = newCsvContent.Split(new[] { ‘\r‘, ‘\n‘ }, StringSplitOptions.RemoveEmptyEntries).Skip(1);
var newCsvWithoutHeader = string.Join(“\r\n“, newCsvLines);
// Append new content to existing content
byte[] newContentBytes = Encoding.UTF8.GetBytes(newCsvWithoutHeader);
memoryStream.Write(newContentBytes, 0, newContentBytes.Length);
// Return the combined file content as byte array
return memoryStream.ToArray();
}
}
}
The ProcessCustomTableJsonAsync method is the one being called from the Custom API. This method is in charge of creating all the columns and rows of the file. now the code that has this method is specific to the requirement that I had. Hence the retrieval of the last created record from a related table, I am also renaming some columns, hence I am using the RenameProperty method.
Finally I am adding new columns to the file, some of them have mathematical operations and some of them have the same value in each row.
So keep in mind that you will have to change this method for your requirement.
Power Automate Cloud Flow
The cloud flow should have an HTTP trigger:
Then it will use the “List rows” action to retrieve the records and then use the “Perform an unbound action” action to call the Custom API. All of this will be inside a “Do until” action to retrieve records in batches of 250 and send those records to the Custom API.
The goal of this approach is to avoid the two-minute runtime limitation that the Custom API has.
Here is a detailed view of each action within the Do until:
In the above action I am using a paging cookie to get the result in pages and of course to get a faster result with higher performance on large datasets. And as you have seen in the images above I am using the paging cookie in the condition of the “Do until” action to repeat the loop until no more records are retrieved.
Then I am calling the Custom API and I am sending as input parameters the batch of 250 records, the tenant id, the client id, the secret value, the SharePoint site ID, the name of the library where the file has to be uploaded, the name of the folder where the file will be placed and the name that the file will have.
Next is update the variables whitin the Do until:
Here is the expression for the Set Variable action:
if
(
empty(outputs(‘List_rows_-_initial_query’)?[‘body’]?[‘@Microsoft.Dynamics.CRM.fetchxmlpagingcookie’]),
”,
replace
(
replace
(
replace
(
decodeUriComponent
(
decodeUriComponent
(
first(split(last(split(outputs(‘List_rows_-_initial_query’)?[‘body’]?[‘@Microsoft.Dynamics.CRM.fetchxmlpagingcookie’], ‘pagingcookie=”‘)), ‘” ‘))
)
),
‘<‘, ‘<’
),
‘>’, ‘>’
),
‘”‘,’"’
)
)
Finally outside of the “Do until” action there is a “Send an email” action to inform the user the file is ready in SharePoint:
And finally don’t forget to create an environment variable to store the cloud flow URL, then you can use the schema name in the JavaScript code:
Testing
Going now to the Model Driven App, we can see we have a button in the view of the investment table, the view has 488 records:
If the user clicks the button a confirm message will be displayed using JavaScript:
If the user clicks ok, a message will be displayed and the cloud flow will be called:
The custom API takes an average of 25 seconds per batch (250 rows) and the entire process took 51 seconds to generate the CSV file with 488 rows:
Now going to SharePoint:
And this is the content of the file, see the columns are displayed with the names I gave them in the handler class:
And the 488 rows are there:
Conclusion
There are different ways to fulfill this requirement, nevertheless I think that combining low-code and pro-code in this scenario it makes sense to generate the file really fast and to avoid the two minutes runtime the Custom API has.
I hope you enjoyed reading it as I enjoyed creating this blog post. Until next time.


Hello there! Welcome to my blog!
My name is Wilmer Alcivar and I’m a Power Platform fan
I’d love to share my knowledge, so please do not hesitate to connect!





