Dynamics 365 integration examples

Dynamics 365 - OData connections for the ERP products and the Dataverse client for customer engagement apps.

Dynamics 365 is a family of products and each exposes its data a little differently, so the first decision is which door to use:

  • Finance and Operations and Business Central - outgoing OData connections via self.odata
  • Customer engagement apps - Sales, Customer Service and everything else built on Dataverse - the DataverseClient

The Dynamics 365 integration overview positions the family as a whole, and the OData system notes cover the connection settings of each ERP product.

Finance and Operations and Business Central

Both speak OData, so a connection configured once in the Dashboard gives services full read and write access with paging and OAuth2 token handling done for you:

# -*- coding: utf-8 -*-

from zato.server.service import Service

class GetCustomers(Service):
    name = 'erp.customer.get-list'

    def handle(self):

        conn = self.odata['Business Central']

        # Companies come first in Business Central
        companies = conn.read('companies')
        company = companies[0]
        company_id = company['id']

        # Ten most recent customers of that company
        customers = conn.read(f'companies({company_id})/customers', top=10)

        for customer in customers:
            number = customer['number']
            display_name = customer['displayName']
            self.logger.info(f'{number} - {display_name}')

The OData query chapter covers filtering, expansion, counting and writes, including the ETag-based concurrency that Business Central enforces on updates.

Dataverse

Dataverse tables sit behind the customer engagement apps and use their own Web API rather than plain OData connections. The DataverseClient wraps it, with MSAL token acquisition included:

# -*- coding: utf-8 -*-

from zato.server.service import DataverseClient, Service

class GetAccounts(Service):
    name = 'crm.account.get-list'

    def handle(self):

        # Credentials kept in a config file, not in code
        config = self.config.dataverse.api

        client = DataverseClient(
            tenant_id=config.tenant_id,
            client_id=config.client_id,
            client_secret=config.client_secret,
            org_url=config.org_url,
        )

        # Read accounts, filtered server-side
        response = client.get('accounts?$filter=revenue gt 1000000&$select=name,revenue')

        for account in response['value']:
            name = account['name']
            revenue = account['revenue']
            self.logger.info(f'{name} - {revenue}')

The full CRUD range is get, post, patch and delete, each taking an entity path:

# Create an account
created = client.post('accounts', {'name': 'Northwind Traders'})

# Update it
client.patch(f'accounts({account_id})', {'telephone1': '+1 555 0100'})

# Delete it
client.delete(f'accounts({account_id})')

One gotcha worth knowing in advance - the entity path is the table's plural logical name, not the display name from the PowerApps UI, so the table shown as "Account" is addressed as accounts. The Dataverse in Python guide walks through a complete scenario, including where the org_url and Entra application details come from.

The config.dataverse.api values above live in an ordinary configuration file called dataverse.ini in your project's config/user-conf directory - the file name and stanza become the self.config.dataverse.api path:

[api]
tenant_id = 221de69a-602d-4a0b-a0a4-1ff2a3943e9f
client_id = 17aaa657-557c-4b18-95c3-71d742fbc6a3
client_secret = MjsrO1zc0.WEV5unJCS5vLa1
org_url = https://org123456.api.crm4.dynamics.com

Business Central through a Microsoft 365 connection

Business Central's API is also reachable through the same Microsoft Graph credentials that a Microsoft 365 connection holds, which matters when one connection should serve both document workflows and ERP reads. The BusinessCentralAdapter base class implements the pattern - a subclass names the connection, the URL and the endpoint, and placeholders are filled in from the request:

# -*- coding: utf-8 -*-

from zato.server.service import BusinessCentralAdapter

class GetCustomerLedger(BusinessCentralAdapter):
    name = 'erp.bc.get-customer-ledger'

    conn_name = 'My MS365 Connection'
    base_url = 'https://api.businesscentral.dynamics.com/v2.0/{tenant_id}/Production/api/v2.0'
    endpoint = 'companies({company_id})/customerPayments'

Each {placeholder} resolves from the service's input payload or from configuration files, and the adapter returns the decoded response, raising on any non-200 reply. It is read-only by design - writes to Business Central go through the OData connection shown earlier.

Testing

Unit tests mock the OData responses with the OData test support, including an in-process test server that simulates Dynamics 365 authentication and validation behavior.