Skip to main content

Dataverse Connector

The Dataverse connector (ctx.connectors.dataverse) reads and writes Dataverse (Dynamics 365) rows, relates rows, calls actions, and moves file and image columns. Locally it calls the Dataverse Web API and shapes the answers the way the Power Automate "Microsoft Dataverse" connector returns them.

entityName is the table's entity set name (the plural, such as accounts or contoso_projects), and recordId the row's GUID.

Authentication​

The connector needs a token for your environment (https://contoso.crm.dynamics.com) with the delegated user_impersonation permission on Dynamics CRM (App ID 00000007-0000-0000-c000-000000000000).

Add the permission to your app registration (see App registration) and name the environment in flowforger.config.json:

{
"auth": {
"clientId": "<client-id>",
"tenantId": "<tenant-id>",
"resources": {
"dataverse": "https://contoso.crm.dynamics.com"
}
}
}
flowforger run flow.ff.ts --auth

--auth requests https://contoso.crm.dynamics.com/user_impersonation when the flow uses a Dataverse action, and uses auth.resources.dataverse as the environment URL. Without that entry the run fails with "auth.resources.dataverse is not configured". flowforger scopes flow.ff.ts shows what a given flow will request.

With an explicit token​

az account get-access-token --resource "https://contoso.crm.dynamics.com" --query accessToken -o tsv
flowforger run flow.ff.ts --dv-url https://contoso.crm.dynamics.com --dv-token <token>

Both --dv-url and --dv-token are needed. An explicit --dv-token wins over --auth for Dataverse.

Environment variables​

Whenever a Dataverse connection is available (either form above), flow parameters backed by environment variables resolve to the environment's current values (the value record first, then the definition's default) instead of the default saved in the flow. --param name=value overrides a single parameter. With --auth, Dataverse is signed in for this even when the flow has no Dataverse action, as long as auth.resources.dataverse is set. See the CLI reference.

Operations​

Call ctx.connectors.dataverse.<Operation>(name, params, connectionReferenceName?). Use the cloud operation id (first column) in flows you deploy. The local aliases run locally but are written into the compiled flow unchanged, so the deployed flow would name an operation the cloud connector does not have.

Cloud operation idDesigner nameLocal aliasesLocal behaviour
ListRecordsList rowsListRows, listRowsSee List rows.
GetItemGet a row by IDGetItemById, GetRecord, RetrieveRowrecordId, $select, $expand.
CreateRecordAdd a new rowCreateRowAnswers 201 with the created row.
UpdateOnlyRecordUpdate a rowUpdateRowPATCH with If-Match: *: a missing row fails with 404. Answers 200 with the updated row.
UpdateRecordUpsert a rowUpsertRow, UpsertRecordPATCH without If-Match: creates the row when the ID is new (201, null columns kept), otherwise updates it (200).
DeleteRecordDelete a rowDeleteRow204, no body.
AssociateEntitiesRelate rowsAssociateRecords204, no body.
DisassociateEntitiesUnrelate rowsDisassociateRecords204, no body.
PerformBoundActionPerform a bound actionSee Actions.
PerformUnboundActionPerform an unbound actionSee Actions.
GetEntityFileImageFieldContentDownload a file or an imageGetFileContentSee Files and images.
UpdateEntityFileImageFieldContentUpload a file or an imageUploadFileContent204, no body.
ExecuteChangesetExecuteBatchSends requests (an array of { method, entityName, recordId?, body?, contentId? } or { method, url, body? }) as one OData changeset and answers { responses: [{ status, body }] }. This shape is FlowForger's own; it has not been compared with the cloud.
GetRelevantRowsSearch rowsRelevanceSearchDataverse search with searchText, entities, filter, orderby, top, skip. Answers with the search API's response; not compared with the cloud.

RetrieveRecord is an alias of GetItem. The editor offers both; the local connector runs either, and a deployed flow carries GetItem. The same goes for the other local names in the table (ListRows, CreateRow, UpsertRow, ...): the emitter writes the cloud operation id.

UpdateRecord is the upsert. It is the cloud's "Upsert a row" and creates the row when the ID does not exist. For a plain update that must not create anything, use UpdateOnlyRecord.

Writing columns​

Column values go in item/<column> keys (an object literal under item is flattened to them), or in a body object:

await ctx.connectors.dataverse.CreateRecord('CreateAccount', {
entityName: 'accounts',
item: {
name: 'Fabrikam',
'primarycontactid@odata.bind': '/contacts(00000000-0000-0000-0000-000000000001)',
},
}, 'shared_commondataserviceforapps');

A column set to an expression that finds nothing is sent as null and clears the column, as in the cloud.

List rows​

ListRecords takes $select, $filter, $orderby, $expand, $top, $skip, $skiptoken, $apply, $count and fetchXml.

  • Rows come back with null columns left out (on the row itself; nulls inside expanded related rows are kept), and with the OData annotations the cloud returns (formatted values and lookup names).
  • A collection-valued $expand keeps the Web API's <navigation>@odata.nextLink per row, pointing at the v9.1 endpoint the cloud connector calls.
  • A FetchXML query answers with the Web API's output as is: link-entity columns as <alias>.<column>, aggregates under their aliases, and @Microsoft.Dynamics.CRM.fetchxmlpagingcookie / morerecords.
  • An OData option set to null or '' (an expression that found nothing) is sent empty, as the cloud sends it ($filter=), and Dataverse rejects it with 400 "The value for OData query '$filter' cannot be empty." Guard such options with ctx.coalesce(...) or leave the key out.

Pagination: with @runtimeConfig {"paginationPolicy":{"minimumItemCount":N}} on the call, the engine follows @odata.nextLink until at least N rows are collected (whole pages) and the result is { value } alone. A FetchXML paging cookie is not followed, so a FetchXML query gives one page. See Outputs and failures.

@Action()
async run(ctx: FlowContext) {
/** @runtimeConfig {"paginationPolicy":{"minimumItemCount":5000}} */
await ctx.connectors.dataverse.ListRecords('ListAccounts', {
entityName: 'accounts',
'$select': 'name,accountnumber',
'$filter': 'statecode eq 0',
}, 'shared_commondataserviceforapps');

await ctx.compose('AccountCount', ctx.length(ctx.body('ListAccounts')?.['value']));
}

Relate and unrelate rows​

await ctx.connectors.dataverse.AssociateEntities('LinkContact', {
entityName: 'accounts',
recordId: ctx.triggerBody()?.['accountId'],
associationEntityRelationship: 'contact_customer_accounts',
'item/@odata.id': 'https://contoso.crm.dynamics.com/api/data/v9.1/contacts(00000000-0000-0000-0000-000000000001)',
}, 'shared_commondataserviceforapps');

AssociateEntities takes the related row's URL in item/@odata.id; DisassociateEntities takes it in $id. The designer writes v9.1 row URLs; the local connector rebases them onto the version it calls.

Actions​

PerformBoundAction (with entityName and recordId) and PerformUnboundAction POST the action named in actionName, exactly as written:

  • A bound action needs its namespace: Microsoft.Dynamics.CRM.<name>, for example Microsoft.Dynamics.CRM.contoso_Recalculate. The plain name fails with 404.
  • An unbound action must not have the namespace: contoso_SendWelcome, WhoAmI. Power Automate refuses to save a namespaced unbound action.
  • Parameters go in item/<parameter> keys (or a nested item). With no parameters, no request body is sent at all, so a missing required parameter fails with Dataverse's "Required field 'X' is missing".
  • The call is a single POST; there is no fallback to calling a function with GET.
  • The answer is the response's properties (nulls left out), or 204 with no body for an action without a response.

Files and images​

GetEntityFileImageFieldContent takes recordId and fileImageFieldName (and size for images). It asks for the first 4 MB (Range: bytes=0-4194303), as the cloud does, so a file answers 206 PartialContent (recorded without outputs.statusCode) and an image 200. The body is binary content { "$content-type", "$content" }.

UpdateEntityFileImageFieldContent takes recordId, fileImageFieldName, the content in item (text, or binary content such as another action's file body) and the file name in x-ms-file-name.

Failures​

A failed call's outputs are { statusCode, body }, where the body is the Web API's own { error: { code, message } }, which is also what the cloud records. Read it with ctx.outputs('X')?.['statusCode'] and ctx.body('X')?.['error']?.['message'] in a catch scope; see Outputs and failures.

Reading a lookup's _<column>_value and passing it to GetItem fails when the lookup is empty. flowforger validate points this out (DV_LOOKUP_UNGUARDED) when no enclosing if checks that column first.

Triggers​

Dataverse triggers ("When a row is added, modified or deleted", SubscribeWebhookTrigger) are written into the compiled flow but never fire locally. Pass the row you want to test with as the trigger body: flowforger run flow.ff.ts --in row.json. See Connector triggers.

Running locally​

flowforger run flow.ff.ts --auth
flowforger run flow.ff.ts --dv-url https://contoso.crm.dynamics.com --dv-token <token>

To run without touching Dataverse, mock the actions (--mock CreateAccount=created.json), or use --live to allow only the reads you name; see Local testing.

More examples: examples/dataverse/ (list-rows.ff.ts, create-row.ff.ts, update-row.ff.ts, delete-row.ff.ts, relationships.ff.ts, batch-operations.ff.ts, custom-actions.ff.ts, search.ff.ts, file-operations.ff.ts).

Packages​

  • @flowforger/connectors-dataverse: the connector
  • @flowforger/dataverse-sdk: a thin Dataverse Web API client, also used for environment-variable resolution