---
name: Certyo + Dynamics 365 Integration
version: 1.0.0
description: Generate Dynamics 365 (Business Central & Finance and Operations) to Certyo integration code with field mappings, auth, and working examples
api_base: https://www.certyos.com
auth: X-API-Key header
last_updated: 2026-04-14
---

# Certyo + Dynamics 365 Integration Skill

This skill enables AI agents to generate production-ready integration code that sends product authenticity records from Microsoft Dynamics 365 Business Central and Finance & Operations to the Certyo blockchain anchoring platform. It covers the recommended Azure Service Bus buffered architecture, AL extension triggers, C# Azure Functions, and Power Automate flows.

## Certyo API Reference

### Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/records` | Ingest a single record (returns 202 Accepted) |
| `POST` | `/api/v1/records/bulk` | Ingest up to 1,000 records in one call |
| `POST` | `/api/v1/verify/record` | Verify a record's blockchain anchoring status |
| `GET` | `/api/v1/records` | Query records by tenant, database, collection, or recordId |

### Authentication

All Certyo API calls require an `X-API-Key` header.

### Record Payload Schema

```json
{
  "tenantId": "string (required)",
  "database": "string (required)",
  "collection": "string (required)",
  "recordId": "string (required)",
  "recordPayload": { "...any JSON (required)" },
  "clientId": "string (optional)",
  "recordVersion": "string (optional, default '1')",
  "operationType": "upsert|insert|update|delete (optional)",
  "sourceTimestamp": "ISO 8601 (optional)",
  "idempotencyKey": "string (optional)"
}
```

### Successful Response (202 Accepted)

```json
{
  "recordId": "string",
  "recordHash": "string (SHA-256)",
  "tenantId": "string",
  "acceptedAt": "ISO 8601",
  "idempotencyReplayed": false
}
```

### Pipeline

Records flow through: Kafka accumulation (1,000 records or 60 seconds) then Merkle tree computation then IPFS pinning then Polygon blockchain anchor. Typical end-to-end anchoring latency is 60-90 seconds.

## Dynamics 365 Integration Pattern

### Architecture

```
D365 Business Central / F&O
  |
  | (AL codeunit / Dual-Write / OData webhook)
  v
Azure Service Bus Queue
  |
  | (ServiceBusTrigger)
  v
Azure Function (C#)
  |
  | POST /api/v1/records or /api/v1/records/bulk
  v
Certyo API
  |
  | (poll GET /api/v1/records?recordId=...)
  v
Azure Function (TimerTrigger) --> OData PATCH back to D365
```

### When to Use This Pattern

- You are running Business Central Online or on-premises with Azure connectivity.
- You need to anchor sales shipments, production orders, quality certificates, or warehouse receipts.
- You want decoupled, retry-safe delivery via Azure Service Bus rather than direct HTTP calls from AL.
- For F&O, use Dual-Write or Data Events to push to Service Bus instead of AL codeunits.

### Rate Limits

Dynamics 365 OData API enforces a limit of 6,000 requests per 5-minute window per environment. Design polling and write-back logic to stay within this budget.

## Authentication

### Dynamics 365 Side

Register an App Registration in Microsoft Entra ID (Azure AD):

1. Create an App Registration with a client secret or certificate.
2. Grant `Dynamics 365 Business Central` API permission (`API.ReadWrite.All`) or the appropriate Dynamics 365 Finance permission.
3. Note the `tenantId`, `clientId`, and `clientSecret`.

```csharp
// Acquire token for D365 OData calls
var credential = new ClientSecretCredential(
    tenantId: "<entra-tenant-id>",
    clientId: "<app-client-id>",
    clientSecret: keyVaultClient.GetSecret("D365ClientSecret").Value.Value
);
var token = await credential.GetTokenAsync(
    new TokenRequestContext(new[] { "https://api.businesscentral.dynamics.com/.default" })
);
```

### Certyo Side

Store the Certyo API key in Azure Key Vault. Never embed it in AL code or environment variables directly.

```csharp
var keyVaultUri = Environment.GetEnvironmentVariable("KEY_VAULT_URI");
var secretClient = new SecretClient(new Uri(keyVaultUri), new DefaultAzureCredential());
var certYoApiKey = (await secretClient.GetSecretAsync("CertyoApiKey")).Value.Value;
```

## Field Mapping

| Dynamics 365 Field | Certyo Field | Notes |
|---|---|---|
| `SalesShipmentHeader."No."` | `recordId` | Unique shipment document number |
| `SalesShipmentHeader."Posting Date"` | `sourceTimestamp` | Convert to ISO 8601 |
| `"dynamics365"` (constant) | `database` | Identifies the source system |
| `"sales-shipments"` (constant) | `collection` | Identifies the document type |
| `SalesShipmentLine."No."` (Item No.) | `recordPayload.itemNumber` | Product identifier |
| `SalesShipmentLine.Quantity` | `recordPayload.quantity` | Shipped quantity |
| `SalesShipmentHeader."Sell-to Customer No."` | `recordPayload.customerNumber` | Customer reference |
| `SalesShipmentHeader."External Document No."` | `recordPayload.externalDocumentNo` | Customer PO or external ref |
| `SalesShipmentLine."Lot No."` | `recordPayload.lotNumber` | Item tracking lot |
| `SalesShipmentLine."Serial No."` | `recordPayload.serialNumber` | Item tracking serial |
| Tenant environment ID | `tenantId` | Your Certyo tenant identifier |
| `"No." + "-v" + Format(version)` | `idempotencyKey` | Prevents duplicate ingestion on retries |

## Code Examples

### Example 1: C# Azure Function with ServiceBusTrigger

This function receives messages from the Azure Service Bus queue that Business Central publishes to, and forwards them to Certyo.

```csharp
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace Certyo.D365.Functions;

public sealed class ShipmentIngestFunction
{
    private readonly HttpClient _http;
    private readonly ILogger<ShipmentIngestFunction> _logger;
    private readonly string _certYoApiKey;
    private readonly string _certYoTenantId;

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

        var kvUri = Environment.GetEnvironmentVariable("KEY_VAULT_URI")!;
        var secretClient = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
        _certYoApiKey = secretClient.GetSecret("CertyoApiKey").Value.Value;
        _certYoTenantId = secretClient.GetSecret("CertyoTenantId").Value.Value;
    }

    [Function(nameof(ShipmentIngestFunction))]
    public async Task Run(
        [ServiceBusTrigger("certyo-shipments", Connection = "ServiceBusConnection")]
        string messageBody,
        FunctionContext context)
    {
        var shipment = JsonSerializer.Deserialize<ShipmentMessage>(messageBody);
        if (shipment is null)
        {
            _logger.LogWarning("Received null shipment message, skipping.");
            return;
        }

        var certYoRecord = new
        {
            tenantId = _certYoTenantId,
            database = "dynamics365",
            collection = "sales-shipments",
            recordId = shipment.ShipmentNo,
            sourceTimestamp = shipment.PostingDate.ToString("o"),
            operationType = "insert",
            idempotencyKey = $"{shipment.ShipmentNo}-v1",
            recordPayload = new
            {
                shipmentNo = shipment.ShipmentNo,
                itemNumber = shipment.ItemNo,
                quantity = shipment.Quantity,
                customerNumber = shipment.CustomerNo,
                externalDocumentNo = shipment.ExternalDocumentNo,
                lotNumber = shipment.LotNo,
                serialNumber = shipment.SerialNo,
                postingDate = shipment.PostingDate.ToString("o")
            }
        };

        var json = JsonSerializer.Serialize(certYoRecord);
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/records")
        {
            Content = new StringContent(json, Encoding.UTF8, "application/json")
        };
        request.Headers.Add("X-API-Key", _certYoApiKey);

        var response = await _http.SendAsync(request);
        response.EnsureSuccessStatusCode();

        var result = await response.Content.ReadAsStringAsync();
        _logger.LogInformation(
            "Shipment {ShipmentNo} ingested into Certyo. Response: {Response}",
            shipment.ShipmentNo, result);
    }
}

public record ShipmentMessage(
    string ShipmentNo,
    DateTime PostingDate,
    string ItemNo,
    decimal Quantity,
    string CustomerNo,
    string ExternalDocumentNo,
    string? LotNo,
    string? SerialNo);
```

### Example 2: AL Extension for Business Central (OnAfterPostSalesShipment)

This AL codeunit fires after a sales shipment is posted and sends a message to Azure Service Bus. It does NOT call the Certyo API directly from AL, which would be fragile and block the posting process.

```al
codeunit 50100 "Certyo Shipment Publisher"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterPostSalesDoc', '', false, false)]
    local procedure OnAfterPostSalesShipment(
        var SalesHeader: Record "Sales Header";
        var GenJnlPostLine: Codeunit "Gen. Jnl.-Post Line";
        SalesShptHdrNo: Code[20];
        RetRcpHdrNo: Code[20];
        SalesInvHdrNo: Code[20];
        SalesCrMemoHdrNo: Code[20])
    var
        SalesShipmentHeader: Record "Sales Shipment Header";
        SalesShipmentLine: Record "Sales Shipment Line";
        ServiceBusHelper: Codeunit "Certyo Service Bus Helper";
        JsonObj: JsonObject;
        PayloadObj: JsonObject;
        LinesArr: JsonArray;
        LineObj: JsonObject;
        MessageText: Text;
    begin
        if SalesShptHdrNo = '' then
            exit;

        if not SalesShipmentHeader.Get(SalesShptHdrNo) then
            exit;

        JsonObj.Add('shipmentNo', SalesShipmentHeader."No.");
        JsonObj.Add('postingDate', Format(SalesShipmentHeader."Posting Date", 0, 9));
        JsonObj.Add('customerNo', SalesShipmentHeader."Sell-to Customer No.");
        JsonObj.Add('externalDocumentNo', SalesShipmentHeader."External Document No.");

        SalesShipmentLine.SetRange("Document No.", SalesShptHdrNo);
        if SalesShipmentLine.FindSet() then
            repeat
                Clear(LineObj);
                LineObj.Add('itemNo', SalesShipmentLine."No.");
                LineObj.Add('quantity', SalesShipmentLine.Quantity);
                LineObj.Add('lotNo', GetLotNo(SalesShipmentLine));
                LineObj.Add('serialNo', GetSerialNo(SalesShipmentLine));
                LinesArr.Add(LineObj);
            until SalesShipmentLine.Next() = 0;

        JsonObj.Add('lines', LinesArr);
        JsonObj.WriteTo(MessageText);

        ServiceBusHelper.SendMessage('certyo-shipments', MessageText);
    end;

    local procedure GetLotNo(SalesShipmentLine: Record "Sales Shipment Line"): Text
    var
        ItemLedgerEntry: Record "Item Ledger Entry";
    begin
        ItemLedgerEntry.SetRange("Document No.", SalesShipmentLine."Document No.");
        ItemLedgerEntry.SetRange("Document Line No.", SalesShipmentLine."Line No.");
        if ItemLedgerEntry.FindFirst() then
            exit(ItemLedgerEntry."Lot No.");
        exit('');
    end;

    local procedure GetSerialNo(SalesShipmentLine: Record "Sales Shipment Line"): Text
    var
        ItemLedgerEntry: Record "Item Ledger Entry";
    begin
        ItemLedgerEntry.SetRange("Document No.", SalesShipmentLine."Document No.");
        ItemLedgerEntry.SetRange("Document Line No.", SalesShipmentLine."Line No.");
        if ItemLedgerEntry.FindFirst() then
            exit(ItemLedgerEntry."Serial No.");
        exit('');
    end;
}
```

### Example 3: Power Automate Flow Definition

This flow triggers on a Business Central webhook when a sales shipment is posted, transforms the data, and sends it to Certyo via HTTP.

```json
{
  "definition": {
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "triggers": {
      "When_a_sales_shipment_is_posted": {
        "type": "ApiConnectionWebhook",
        "inputs": {
          "host": {
            "connection": { "name": "@parameters('$connections')['businesscentral']['connectionId']" }
          },
          "body": {
            "NotificationUrl": "@{listCallbackUrl()}"
          },
          "path": "/v2.0/@{encodeURIComponent(encodeURIComponent('production'))}/api/v2.0/companies(@{encodeURIComponent(encodeURIComponent(parameters('companyId')))})/salesShipments",
          "method": "post"
        }
      }
    },
    "actions": {
      "Get_shipment_details": {
        "type": "ApiConnection",
        "inputs": {
          "host": {
            "connection": { "name": "@parameters('$connections')['businesscentral']['connectionId']" }
          },
          "method": "get",
          "path": "/v2.0/@{encodeURIComponent('production')}/api/v2.0/companies(@{encodeURIComponent(parameters('companyId'))})/salesShipments(@{triggerBody()?['id']})?$expand=salesShipmentLines"
        },
        "runAfter": {}
      },
      "Get_Certyo_API_Key": {
        "type": "ApiConnection",
        "inputs": {
          "host": {
            "connection": { "name": "@parameters('$connections')['keyvault']['connectionId']" }
          },
          "method": "get",
          "path": "/secrets/@{encodeURIComponent('CertyoApiKey')}/value"
        },
        "runAfter": {}
      },
      "Send_to_Certyo": {
        "type": "Http",
        "inputs": {
          "method": "POST",
          "uri": "https://www.certyos.com/api/v1/records",
          "headers": {
            "Content-Type": "application/json",
            "X-API-Key": "@body('Get_Certyo_API_Key')?['value']"
          },
          "body": {
            "tenantId": "@parameters('certyoTenantId')",
            "database": "dynamics365",
            "collection": "sales-shipments",
            "recordId": "@body('Get_shipment_details')?['number']",
            "sourceTimestamp": "@body('Get_shipment_details')?['shipmentDate']",
            "operationType": "insert",
            "idempotencyKey": "@{body('Get_shipment_details')?['number']}-v1",
            "recordPayload": {
              "shipmentNo": "@body('Get_shipment_details')?['number']",
              "customerNumber": "@body('Get_shipment_details')?['sellToCustomerNumber']",
              "shipmentDate": "@body('Get_shipment_details')?['shipmentDate']",
              "lines": "@body('Get_shipment_details')?['salesShipmentLines']"
            }
          }
        },
        "runAfter": {
          "Get_shipment_details": ["Succeeded"],
          "Get_Certyo_API_Key": ["Succeeded"]
        }
      },
      "Update_BC_custom_fields": {
        "type": "ApiConnection",
        "inputs": {
          "host": {
            "connection": { "name": "@parameters('$connections')['businesscentral']['connectionId']" }
          },
          "method": "patch",
          "path": "/v2.0/@{encodeURIComponent('production')}/api/v2.0/companies(@{encodeURIComponent(parameters('companyId'))})/salesShipments(@{triggerBody()?['id']})",
          "headers": {
            "If-Match": "*"
          },
          "body": {
            "certyoRecordHash": "@body('Send_to_Certyo')?['recordHash']",
            "certyoStatus": "Ingested"
          }
        },
        "runAfter": {
          "Send_to_Certyo": ["Succeeded"]
        }
      }
    }
  },
  "parameters": {
    "$connections": { "defaultValue": {}, "type": "Object" },
    "certyoTenantId": { "defaultValue": "", "type": "String" },
    "companyId": { "defaultValue": "", "type": "String" }
  }
}
```

### Example 4: Bulk Ingest via Azure Function (TimerTrigger)

For high-volume F&O scenarios using Dual-Write or batch jobs, use the bulk endpoint.

```csharp
using System.Text;
using System.Text.Json;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace Certyo.D365.Functions;

public sealed class BulkShipmentIngestFunction
{
    private readonly HttpClient _http;
    private readonly ILogger<BulkShipmentIngestFunction> _logger;
    private readonly string _certYoApiKey;
    private readonly string _certYoTenantId;

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

        var kvUri = Environment.GetEnvironmentVariable("KEY_VAULT_URI")!;
        var secretClient = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
        _certYoApiKey = secretClient.GetSecret("CertyoApiKey").Value.Value;
        _certYoTenantId = secretClient.GetSecret("CertyoTenantId").Value.Value;
    }

    [Function(nameof(BulkShipmentIngestFunction))]
    public async Task Run(
        [ServiceBusTrigger("certyo-shipments-bulk", Connection = "ServiceBusConnection",
            IsBatched = true)]
        string[] messages,
        FunctionContext context)
    {
        if (messages.Length == 0) return;

        var records = messages
            .Select(m => JsonSerializer.Deserialize<ShipmentMessage>(m))
            .Where(s => s is not null)
            .Select(s => new
            {
                tenantId = _certYoTenantId,
                database = "dynamics365",
                collection = "sales-shipments",
                recordId = s!.ShipmentNo,
                sourceTimestamp = s.PostingDate.ToString("o"),
                operationType = "insert",
                idempotencyKey = $"{s.ShipmentNo}-v1",
                recordPayload = new
                {
                    shipmentNo = s.ShipmentNo,
                    itemNumber = s.ItemNo,
                    quantity = s.Quantity,
                    customerNumber = s.CustomerNo
                }
            })
            .ToList();

        // Certyo bulk endpoint accepts up to 1000 records
        foreach (var batch in records.Chunk(1000))
        {
            var json = JsonSerializer.Serialize(batch);
            var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/records/bulk")
            {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            };
            request.Headers.Add("X-API-Key", _certYoApiKey);

            var response = await _http.SendAsync(request);
            response.EnsureSuccessStatusCode();

            _logger.LogInformation("Bulk ingested {Count} records into Certyo.", batch.Length);
        }
    }
}
```

## Verification and Write-back

After records are ingested, they pass through the Certyo pipeline (Kafka, accumulation, Merkle tree, IPFS, Polygon). Anchoring typically completes in 60-90 seconds. Use a TimerTrigger Azure Function to poll and update D365.

```csharp
[Function("CertyoVerificationPoller")]
public async Task PollAndWriteBack(
    [TimerTrigger("0 */2 * * * *")] TimerInfo timer,
    FunctionContext context)
{
    // 1. Query Certyo for recently ingested records that are now anchored
    var request = new HttpRequestMessage(HttpMethod.Get,
        $"/api/v1/records?database=dynamics365&collection=sales-shipments");
    request.Headers.Add("X-API-Key", _certYoApiKey);

    var response = await _http.SendAsync(request);
    var records = await JsonSerializer.DeserializeAsync<List<CertyoRecord>>(
        await response.Content.ReadAsStreamAsync());

    foreach (var record in records!.Where(r => r.Status == "Anchored"))
    {
        // 2. Verify on-chain
        var verifyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/verify/record")
        {
            Content = new StringContent(
                JsonSerializer.Serialize(new
                {
                    tenantId = record.TenantId,
                    database = record.Database,
                    collection = record.Collection,
                    recordId = record.RecordId
                }),
                Encoding.UTF8, "application/json")
        };
        verifyRequest.Headers.Add("X-API-Key", _certYoApiKey);
        var verifyResponse = await _http.SendAsync(verifyRequest);

        if (!verifyResponse.IsSuccessStatusCode) continue;

        // 3. Write back to D365 Business Central via OData
        var d365Token = await GetD365Token();
        var patchUrl = $"{_d365BaseUrl}/salesShipments?$filter=number eq '{record.RecordId}'";

        var patchRequest = new HttpRequestMessage(HttpMethod.Patch, patchUrl)
        {
            Content = new StringContent(
                JsonSerializer.Serialize(new
                {
                    certyoRecordHash = record.RecordHash,
                    certyoAnchorStatus = "Anchored",
                    certyoAnchoredAt = DateTime.UtcNow.ToString("o")
                }),
                Encoding.UTF8, "application/json")
        };
        patchRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", d365Token);
        patchRequest.Headers.Add("If-Match", "*");

        await _http.SendAsync(patchRequest);
        _logger.LogInformation("Wrote anchor status back to D365 for {RecordId}.", record.RecordId);
    }
}
```

## Code Generation Rules

1. **Use Azure Service Bus as a buffer.** Never call the Certyo HTTP API directly from AL code. AL should only publish messages to Service Bus; an Azure Function consumes and forwards to Certyo.

2. **Store all secrets in Azure Key Vault.** The Certyo API key, D365 client secret, and Service Bus connection strings must come from Key Vault via `DefaultAzureCredential`. Never embed secrets in app settings or AL code.

3. **Use the bulk endpoint for batches.** When the Azure Function receives more than 10 messages in a batch (e.g., from F&O Dual-Write), use `POST /api/v1/records/bulk` instead of calling the single-record endpoint in a loop.

4. **Always set `idempotencyKey`.** Use the pattern `{documentNo}-v{version}` to prevent duplicate ingestion when Service Bus redelivers messages. This is critical because Service Bus provides at-least-once delivery.

5. **Map `sourceTimestamp` to the D365 posting date.** Convert the D365 date to ISO 8601 format. This preserves the business-meaningful timestamp, not the time of API call.

6. **Use Dual-Write for Finance & Operations.** F&O does not have AL; use Dual-Write to sync entities to Dataverse, then trigger a Power Automate flow or Logic App from the Dataverse change event.

7. **Respect the OData rate limit.** D365 OData API allows 6,000 requests per 5-minute sliding window. When writing verification results back in bulk, space out PATCH calls or use batch OData requests (`$batch` endpoint).

8. **Set `operationType` correctly.** Use `insert` for new shipments, `update` for corrections, `delete` for cancellations. This maps to the Certyo audit trail.

9. **Handle Service Bus dead-letter queue.** Configure a dead-letter handler that alerts on repeated failures. Messages that fail Certyo ingestion (e.g., 400 validation errors) should be logged and not retried endlessly.

10. **Keep AL extensions minimal.** The AL codeunit should only serialize the document to JSON and send it to Service Bus. All transformation, mapping, and Certyo-specific logic belongs in the Azure Function.
