Skip to main contentCertyo Developer Portal

Microsoft Dynamics 365

Connectez Dynamics 365 Business Central et Finance & Operations a Certyo pour des certificats d'authenticite ancres sur la blockchain pour chaque expedition, mouvement de stock et transaction de vente.

Apercu

Dynamics 365 couvre deux familles de produits distinctes avec des surfaces d'integration differentes. Ce guide fournit des modeles prets pour la production pour les deux :

  • Business Central — Extensions AL, API Pages OData v4 et webhooks pour les clients PME utilisant Business Central Online ou on-premise.
  • Finance & Operations (F&O) — Dataverse Dual-Write, Business Events et endpoints OData Data Entity pour les clients entreprise utilisant Supply Chain Management, Commerce ou Finance.

Prerequis

  • Tenant Azure avec Microsoft Entra ID (anciennement Azure AD)
  • Abonnement Dynamics 365 Business Central ou Finance & Operations
  • Cle API Certyo depuis le backoffice (certyo_sk_live_...)
  • Namespace Azure Service Bus (niveau Standard ou superieur)
  • Runtime Azure Functions (v4, .NET 8 isolated worker)

Architecture

Les deux modeles d'integration suivent le meme flux evenementiel. L'ERP emet un evenement de domaine, l'infrastructure Azure decouple les systemes et une fonction serverless appelle 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                    │
        └─────────────────────────────────────────────────────┘

Integration Business Central

Business Central expose les donnees via des API Pages OData v4 et supporte les extensions AL pour la logique personnalisee. Le modele recommande consiste a emettre un message Service Bus depuis un Codeunit AL lors de la validation d'une expedition de vente.

Option A : Extension AL (recommandee)

Abonnez-vous a l'evenement OnAfterPostSalesShipment, construisez le payload d'enregistrement Certyo et envoyez-le a Azure Service Bus. La Azure Function le recupere et appelle Certyo.

Option B : API Page OData v4 + Power Automate

Exposez une API Page personnalisee avec les champs necessaires a Certyo, puis utilisez Power Automate pour interroger les nouvelles expeditions et les transmettre. Cette approche est moins technique mais introduit une latence de polling.

Avis de depreciation OData D365
Microsoft deprecie les anciens endpoints OData d'interface utilisateur (par ex. /data/ sur F&O, /ODataV4/ sur BC sans prefixe API) en 2027. Utilisez les endpoints /api/v2.0/ pour Business Central et /data/v1/ Data Entity pour F&O. Tous les exemples de ce guide utilisent la surface API supportee.

Integration Finance & Operations

F&O fournit les Business Events et Dataverse Dual-Write pour l'integration en temps reel. Le modele recommande utilise les Business Events pour envoyer les evenements de transaction de stock a Azure Service Bus, ou une Azure Function les transforme et les transmet a Certyo.

Modele Dataverse Dual-Write

Lorsqu'une reception de marchandises ou un transfert de stock est valide dans F&O, Dual-Write synchronise l'entite vers Dataverse. Un flux Power Automate ou un plugin Dataverse detecte le changement de ligne et appelle Certyo. Ce modele fonctionne bien si vous utilisez deja Dual-Write pour d'autres integrations.

Modele Business Events (recommande)

Enregistrez un Business Event sur l'entite InventTransPosting. Configurez l'endpoint pour livrer a Azure Service Bus. La Azure Function consomme le message et appelle Certyo avec les details du document materiel.


Exemples de code

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; }
}

Configuration de la Azure Function

Enregistrez le HttpClient nomme Certyo dans Program.cs pour que la fonction puisse appeler l'API avec l'adresse de base et l'en-tete de cle API corrects :

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();

Parametres d'application requis

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"
  }
}

Authentification

Cote Dynamics 365

Enregistrez une App Registration dans Microsoft Entra ID avec la permission API Dynamics 365 Business Central ou Dynamics 365 Finance and Operations. Utilisez le flux OAuth 2.0 client credentials pour obtenir un jeton Bearer :

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"

Pour Finance & Operations, remplacez le scope par https://your-environment.operations.dynamics.com/.default.

Cote Certyo

Certyo utilise une simple cle API dans l'en-tete X-API-Key. Stockez la cle dans Azure Key Vault et referencez-la depuis les parametres de votre Function App via des references Key Vault :

# 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/)

Limites de debit et strategie de regroupement

Dynamics 365 impose des limites de debit API que vous devez prendre en compte dans votre integration :

  • Business Central: 6 000 requetes API OData par fenetre glissante de 5 minutes par environnement. Le depassement retourne 429 Too Many Requests.
  • Finance & Operations: 6 000 requetes par fenetre de 5 minutes par utilisateur, avec un systeme de limitation base sur la priorite en charge maximale.
  • Certyo: L'endpoint d'enregistrement unitaire a des limites genereuses, mais pour les scenarios a haut volume, utilisez POST /api/v1/records/bulk (jusqu'a 1 000 enregistrements par requete).

Strategie de regroupement recommandee : Si votre environnement D365 traite plus de 100 expeditions par minute, accumulez les evenements dans la file Service Bus et configurez la Azure Function avec un declencheur batch avec MaxMessageBatchSize = 100. Mappez chaque lot vers un seul appel POST /api/v1/records/bulk :

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 et ecriture retour

Apres l'ancrage des enregistrements (generalement 60-90 secondes), vous pouvez les verifier et reecrire le statut du certificat dans les champs personnalises de Dynamics 365. Utilisez une Azure Function declenchee par timer qui interroge Certyo et met a jour 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);
        }
    }
}
Champs personnalises dans D365
Avant d'executer la fonction d'ecriture retour, creez des champs personnalises (certyoVerified, certyoRecordHash, certyoMerkleRoot, certyoPolygonTx, certyoVerifiedAt) sur la table Sales Shipment Header dans Business Central via une extension AL, ou sur la Data Entity correspondante dans F&O.

Tests

  1. Deployez la Azure Function sur un slot de staging et configurez-la avec votre cle sandbox Certyo. (certyo_sk_test_...)
  2. Validez une expedition de vente dans Business Central (ou une reception de marchandises dans F&O) et confirmez que le message apparait dans la file Service Bus.
  3. Verifiez que la Azure Function a traite le message en consultant les logs Application Insights pour la sortie recordHash.
  4. Attendez 60-90 secondes, puis appelez POST /api/v1/verify/record avec les donnees d'expedition pour confirmer l'ancrage on-chain.
  5. Verifiez l'enregistrement d'expedition de vente D365 pour les champs personnalises Certyo remplis.

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

  • Authentication — OAuth 2.0 client credentials avec Entra ID et configuration Key Vault
  • Architecture — Evenement D365 → Azure Service Bus → Azure Function → pipeline Certyo
  • Field mapping — Champs SalesShipmentHeader, Item et Line vers le schema d'enregistrement Certyo
  • Code examples — Azure Function C#, extension AL pour Business Central, flux Power Automate
  • Verification — Polling TimerTrigger et ecriture retour OData PATCH vers les champs personnalises D365
  • Rate limiting — OData D365 6 000 req/fenetre de 5 min et strategies de regroupement

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 "Generez une Azure Function qui ingere les evenements d'expedition D365 dans 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,
 generez une azure function qui ingere les evenements d'expedition d365 dans 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