Skip to main contentCertyo Developer Portal

Microsoft Dynamics 365

Connect Dynamics 365 Business Central and Finance & Operations to Certyo for blockchain-backed authenticity certificates on every shipment, inventory movement, and sales transaction.

Overview

Dynamics 365 covers two distinct product families with different integration surfaces. This guide provides production-ready patterns for both:

  • Business CentralAL extensions, OData v4 API Pages, and webhooks for SMB customers running Business Central Online or on-premises.
  • Finance & Operations (F&O)Dataverse Dual-Write, Business Events, and Data Entity OData endpoints for enterprise customers running Supply Chain Management, Commerce, or Finance.

Prerequisites

  • Azure tenant with Microsoft Entra ID (formerly Azure AD)
  • Dynamics 365 Business Central or Finance & Operations subscription
  • Certyo API key from the backoffice (certyo_sk_live_...)
  • Azure Service Bus namespace (Standard tier or higher)
  • Azure Functions runtime (v4, .NET 8 isolated worker)

Architecture

Both integration patterns follow the same event-driven flow. The ERP emits a domain event, Azure infrastructure decouples the systems, and a serverless function calls Certyo:

Event-Driven Architecturebash
┌─────────────────────┐     ┌──────────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Dynamics 365       │     │  Azure           │     │  Azure Function  │     │  Certyo API  │
│                     │────>│  Service Bus     │────>│                  │────>│              │
│  Business Central   │     │                  │     │  CertyoIngest    │     │  POST        │
│  or F&O             │     │  certyo-records  │     │  Function        │     │  /api/v1/    │
│                     │     │  queue           │     │                  │     │  records     │
└─────────────────────┘     └──────────────────┘     └──────────────────┘     └──────────────┘
        │                                                     │
        │  Business Central:                                  │  Polls Certyo for
        │  AL Codeunit → HttpClient                           │  anchor status, then
        │  or Webhook subscription                            │  writes certificate
        │                                                     │  back to D365 via
        │  Finance & Operations:                              │  OData PATCH
        │  Business Event → Service Bus                       │
        │  or Dataverse Dual-Write trigger                    │
        └─────────────────────────────────────────────────────┘

Business Central Integration

Business Central exposes data through OData v4 API Pages and supports AL extensions for custom logic. The recommended pattern is to fire a Service Bus message from an AL Codeunit when a Sales Shipment is posted.

Option A: AL Extension (recommended)

Subscribe to the OnAfterPostSalesShipment event, build the Certyo record payload, and send it to Azure Service Bus. The Azure Function picks it up and calls Certyo.

Option B: OData v4 API Page + Power Automate

Expose a custom API Page with the fields Certyo needs, then use Power Automate to poll for new shipments and forward them. This is lower-code but introduces polling latency.

D365 OData deprecation notice
Microsoft is deprecating legacy UI OData endpoints (e.g. /data/ on F&O, /ODataV4/ on BC without API prefix) in 2027. Use the /api/v2.0/ endpoints for Business Central and /data/v1/ Data Entity endpoints for F&O. All examples in this guide use the supported API surface.

Finance & Operations Integration

F&O provides Business Events and Dataverse Dual-Write for real-time integration. The recommended pattern uses Business Events to push inventory transaction events to Azure Service Bus, where an Azure Function transforms and forwards them to Certyo.

Dataverse Dual-Write pattern

When a Goods Receipt or Inventory Transfer is posted in F&O, Dual-Write synchronizes the entity to Dataverse. A Power Automate flow or Dataverse plugin detects the row change and calls Certyo. This pattern works well when you already use Dual-Write for other integrations.

Business Events pattern (recommended)

Register a Business Event on the InventTransPosting entity. Configure the endpoint to deliver to Azure Service Bus. The Azure Function consumes the message and calls Certyo with the material document details.


Code Samples

CertyoIngestFunction.cs — Azure Function (Service Bus trigger)csharp
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
using System.Text.Json;

namespace Certyo.Dynamics365.Functions;

public sealed class CertyoIngestFunction
{
    private readonly HttpClient _http;
    private readonly ILogger<CertyoIngestFunction> _logger;

    public CertyoIngestFunction(
        IHttpClientFactory httpClientFactory,
        ILogger<CertyoIngestFunction> logger)
    {
        _http = httpClientFactory.CreateClient("Certyo");
        _logger = logger;
    }

    [Function("CertyoIngest")]
    public async Task Run(
        [ServiceBusTrigger("certyo-records", Connection = "ServiceBusConnection")]
        ServiceBusReceivedMessage message,
        CancellationToken cancellationToken)
    {
        var d365Event = JsonSerializer.Deserialize<D365ShipmentEvent>(
            message.Body.ToString(),
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (d365Event is null)
        {
            _logger.LogWarning("Received null event, skipping");
            return;
        }

        var certyoRecord = new
        {
            tenantId = Environment.GetEnvironmentVariable("CERTYO_TENANT_ID"),
            database = "dynamics365",
            collection = d365Event.EntityName,
            recordId = d365Event.DocumentNo,
            recordVersion = d365Event.Version ?? "1",
            operationType = "insert",
            sourceTimestamp = d365Event.PostingDate.ToString("O"),
            idempotencyKey = $"{d365Event.DocumentNo}-{d365Event.Version ?? "1"}-{d365Event.PostingDate:yyyy-MM-dd}",
            recordPayload = new
            {
                documentNo = d365Event.DocumentNo,
                entityName = d365Event.EntityName,
                sellToCustomerNo = d365Event.SellToCustomerNo,
                sellToCustomerName = d365Event.SellToCustomerName,
                shipToCode = d365Event.ShipToCode,
                postingDate = d365Event.PostingDate,
                shipmentMethodCode = d365Event.ShipmentMethodCode,
                trackingNo = d365Event.TrackingNo,
                lines = d365Event.Lines.Select(l => new
                {
                    lineNo = l.LineNo,
                    itemNo = l.ItemNo,
                    description = l.Description,
                    quantity = l.Quantity,
                    unitOfMeasureCode = l.UnitOfMeasureCode,
                    lotNo = l.LotNo,
                    serialNo = l.SerialNo
                })
            }
        };

        var response = await _http.PostAsJsonAsync(
            "/api/v1/records",
            certyoRecord,
            cancellationToken);

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content.ReadAsStringAsync(cancellationToken);
            _logger.LogError(
                "Certyo ingestion failed for {DocumentNo}: {Status} {Body}",
                d365Event.DocumentNo, response.StatusCode, body);
            throw new InvalidOperationException(
                $"Certyo returned {response.StatusCode}");
        }

        var result = await response.Content
            .ReadFromJsonAsync<JsonElement>(cancellationToken);
        _logger.LogInformation(
            "Ingested {DocumentNo} -> recordHash: {Hash}",
            d365Event.DocumentNo,
            result.GetProperty("recordHash").GetString());
    }
}

public record D365ShipmentEvent
{
    public string DocumentNo { get; init; } = "";
    public string EntityName { get; init; } = "SalesShipmentHeader";
    public string SellToCustomerNo { get; init; } = "";
    public string SellToCustomerName { get; init; } = "";
    public string ShipToCode { get; init; } = "";
    public DateTime PostingDate { get; init; }
    public string ShipmentMethodCode { get; init; } = "";
    public string TrackingNo { get; init; } = "";
    public string? Version { get; init; }
    public List<ShipmentLine> Lines { get; init; } = new();
}

public record ShipmentLine
{
    public int LineNo { get; init; }
    public string ItemNo { get; init; } = "";
    public string Description { get; init; } = "";
    public decimal Quantity { get; init; }
    public string UnitOfMeasureCode { get; init; } = "";
    public string? LotNo { get; init; }
    public string? SerialNo { get; init; }
}

Azure Function Configuration

Register the Certyo named HttpClient in Program.cs so the function can call the API with the correct base address and API key header:

Program.cscsharp
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        services.AddHttpClient("Certyo", client =>
        {
            client.BaseAddress = new Uri("https://www.certyos.com");
            client.DefaultRequestHeaders.Add(
                "X-API-Key",
                Environment.GetEnvironmentVariable("CERTYO_API_KEY"));
        });
    })
    .Build();

host.Run();

Required app settings

local.settings.jsonjson
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "ServiceBusConnection": "<your-service-bus-connection-string>",
    "CERTYO_API_KEY": "certyo_sk_live_abc123...",
    "CERTYO_TENANT_ID": "your-tenant-id"
  }
}

Authentication

Dynamics 365 side

Register an App Registration in Microsoft Entra ID with the Dynamics 365 Business Central or Dynamics 365 Finance and Operations API permission. Use OAuth 2.0 client credentials flow to obtain a bearer token:

Obtain D365 access tokenbash
curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id={app-id}" \
  -d "client_secret={client-secret}" \
  -d "scope=https://api.businesscentral.dynamics.com/.default" \
  -d "grant_type=client_credentials"

For Finance & Operations, replace the scope with https://your-environment.operations.dynamics.com/.default.

Certyo side

Certyo uses a simple API key in the X-API-Key header. Store the key in Azure Key Vault and reference it from your Function App settings using Key Vault references:

# Key Vault reference syntax in Azure Function app settings
CERTYO_API_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/certyo-api-key/)

Rate Limits & Batching Strategy

Dynamics 365 enforces API rate limits that you must account for in your integration:

  • Business Central: 6,000 OData API requests per 5-minute sliding window per environment. Exceeding this returns 429 Too Many Requests.
  • Finance & Operations: 6,000 requests per 5-minute window per user, with a priority-based throttling system at peak load.
  • Certyo: The single-record endpoint has generous limits, but for high-volume scenarios, use POST /api/v1/records/bulk (up to 1,000 records per request).

Recommended batching strategy: If your D365 environment processes more than 100 shipments per minute, accumulate events in the Service Bus queue and have the Azure Function use a batch trigger with MaxMessageBatchSize = 100. Map each batch to a single POST /api/v1/records/bulk call:

Batch Service Bus triggercsharp
[Function("CertyoBatchIngest")]
public async Task RunBatch(
    [ServiceBusTrigger("certyo-records", Connection = "ServiceBusConnection",
        IsBatched = true)]
    ServiceBusReceivedMessage[] messages,
    CancellationToken cancellationToken)
{
    var records = messages
        .Select(m => JsonSerializer.Deserialize<D365ShipmentEvent>(m.Body.ToString()))
        .Where(e => e is not null)
        .Select(e => new
        {
            database = "dynamics365",
            collection = e!.EntityName,
            recordId = e.DocumentNo,
            recordVersion = e.Version ?? "1",
            operationType = "insert",
            sourceTimestamp = e.PostingDate.ToString("O"),
            idempotencyKey = $"{e.DocumentNo}-{e.Version ?? "1"}-{e.PostingDate:yyyy-MM-dd}",
            recordPayload = new
            {
                documentNo = e.DocumentNo,
                entityName = e.EntityName,
                sellToCustomerNo = e.SellToCustomerNo,
                sellToCustomerName = e.SellToCustomerName,
                postingDate = e.PostingDate,
                trackingNo = e.TrackingNo
            }
        })
        .ToList();

    var bulkPayload = new
    {
        tenantId = Environment.GetEnvironmentVariable("CERTYO_TENANT_ID"),
        records
    };

    var response = await _http.PostAsJsonAsync(
        "/api/v1/records/bulk", bulkPayload, cancellationToken);
    response.EnsureSuccessStatusCode();

    _logger.LogInformation("Bulk ingested {Count} records", records.Count);
}

Verification & Write-Back

After records are anchored (typically 60-90 seconds), you can verify them and write the certificate status back to Dynamics 365 custom fields. Use a timer-triggered Azure Function that polls Certyo and patches D365 via OData:

CertyoVerifyAndWriteBack.cscsharp
[Function("CertyoVerifyWriteBack")]
public async Task RunVerify(
    [TimerTrigger("0 */2 * * * *")] TimerInfo timer,
    CancellationToken cancellationToken)
{
    var tenantId = Environment.GetEnvironmentVariable("CERTYO_TENANT_ID");

    // Query Certyo for recently ingested records pending verification
    var queryResponse = await _http.GetAsync(
        $"/api/v1/records?tenantId={tenantId}&database=dynamics365&status=Anchored&verifiedInErp=false&limit=50",
        cancellationToken);
    var records = await queryResponse.Content
        .ReadFromJsonAsync<RecordQueryResult>(cancellationToken);

    foreach (var record in records?.Items ?? [])
    {
        // Verify the record on-chain
        var verifyResponse = await _http.PostAsJsonAsync(
            "/api/v1/verify/record",
            new
            {
                tenantId,
                database = record.Database,
                collection = record.Collection,
                recordId = record.RecordId,
                payload = record.RecordPayload
            },
            cancellationToken);

        var verification = await verifyResponse.Content
            .ReadFromJsonAsync<JsonElement>(cancellationToken);

        if (verification.GetProperty("verified").GetBoolean())
        {
            // Write certificate data back to D365 via OData
            var d365Token = await GetD365AccessToken(cancellationToken);
            var patchPayload = new
            {
                certyoVerified = true,
                certyoRecordHash = verification.GetProperty("snapshotHash").GetString(),
                certyoMerkleRoot = verification.GetProperty("merkleRoot").GetString(),
                certyoPolygonTx = verification.GetProperty("onChainProof")
                    .GetProperty("polygonScanEventUrl").GetString(),
                certyoVerifiedAt = DateTime.UtcNow
            };

            using var patchRequest = new HttpRequestMessage(
                HttpMethod.Patch,
                $"https://api.businesscentral.dynamics.com/v2.0/{tenantId}/Production/api/v2.0/companies({{companyId}})/salesShipments({{shipmentId}})");
            patchRequest.Headers.Authorization =
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", d365Token);
            patchRequest.Content = JsonContent.Create(patchPayload);

            await new HttpClient().SendAsync(patchRequest, cancellationToken);

            _logger.LogInformation(
                "Wrote certificate back to D365 for {RecordId}: {TxUrl}",
                record.RecordId,
                patchPayload.certyoPolygonTx);
        }
    }
}
Custom fields in D365
Before running the write-back function, create custom fields (certyoVerified, certyoRecordHash, certyoMerkleRoot, certyoPolygonTx, certyoVerifiedAt) on the Sales Shipment Header table in Business Central via AL extension, or on the relevant Data Entity in F&O.

Testing

  1. Deploy the Azure Function to a staging slot and configure it with your Certyo sandbox key. (certyo_sk_test_...)
  2. Post a Sales Shipment in Business Central (or a Goods Receipt in F&O) and confirm the message appears in the Service Bus queue.
  3. Verify the Azure Function processed the message by checking Application Insights logs for the recordHash output.
  4. Wait 60-90 seconds, then call POST /api/v1/verify/record with the shipment data to confirm on-chain anchoring.
  5. Check the D365 Sales Shipment record for the populated Certyo custom fields.

AI Integration · v1.0.0

AI Integration Skill

Download a skill file that enables AI agents to generate working Microsoft Dynamics 365 + Certyo integration code for any language or framework.

v1.0.0
What is this?
A markdown file containing Microsoft Dynamics 365-specific field mappings, authentication setup, code examples, and integration patterns for Certyo. Drop it into your AI agent's context and ask it to generate integration code.

What's inside

  • AuthenticationOAuth 2.0 client credentials with Entra ID and Key Vault setup
  • ArchitectureD365 event → Azure Service Bus → Azure Function → Certyo pipeline
  • Field mappingSalesShipmentHeader, Item, and Line fields to Certyo record schema
  • Code examplesC# Azure Function, AL extension for Business Central, Power Automate flow
  • VerificationTimerTrigger polling and OData PATCH write-back to D365 custom fields
  • Rate limitingD365 OData 6,000 req/5-min window and batching strategies

How to use

Claude Code

Place the file in your project's .claude/commands/ directory, then use it as a slash command:

# Download the skill file
mkdir -p .claude/commands
curl -o .claude/commands/certyo-dynamics365.md \
  https://www.certyos.com/developers/skills/certyo-dynamics365-skill.md

# Use it in Claude Code
/certyo-dynamics365 "Generate an Azure Function that ingests D365 shipment events into Certyo"

Cursor / Copilot / Any AI Agent

Add the file to your project root or attach it to a conversation. The AI agent will use the Microsoft Dynamics 365-specific patterns, field mappings, and code examples to generate correct integration code.

# Add to your project
curl -o CERTYO_DYNAMICS365.md \
  https://www.certyos.com/developers/skills/certyo-dynamics365-skill.md

# Then in your AI agent:
"Using the Certyo Microsoft Dynamics 365 spec in CERTYO_DYNAMICS365.md,
 generate an azure function that ingests d365 shipment events into certyo"

CLAUDE.md Context File

Append the skill file to your project's CLAUDE.md so every Claude conversation has Microsoft Dynamics 365 + Certyo context automatically.

# Append to your project's CLAUDE.md
echo "" >> CLAUDE.md
echo "## Certyo Microsoft Dynamics 365 Integration" >> CLAUDE.md
cat CERTYO_DYNAMICS365.md >> CLAUDE.md