Every integration project in D365 F&O eventually faces the same question: how does an external system know when something important has happened inside the ERP?
The traditional answer was polling — a Logic App or external service querying an OData endpoint on a schedule, checking whether something had changed. Polling is resource-heavy, introduces latency, and creates unnecessary load on the F&O environment.Business Events flip the model. Instead of the external system asking "has anything changed?", D365 F&O pushes a notification the moment a business process completes — vendor created, purchase order confirmed, sales invoice posted. External systems react in near-real-time without polling a single endpoint.
This article covers the complete developer journey: understanding the framework architecture, building a custom business event from scratch with verified X++ code, extending a standard event payload, triggering correctly using BusinessEventsConfigurationReader, and connecting to Power Automate and Azure Logic Apps as consuming endpoints.
How the Business Events framework works
Understanding the flow from trigger to external system helps you design events correctly and debug delivery failures when they occur.
The key architectural point: the event payload is written to the BusinessEventsCommitLog staging table inside the same database transaction as the business process. If the transaction rolls back — journal post fails, order confirmation is cancelled — the event is not sent. This guarantees that external systems never receive a false notification for a process that did not complete.
Endpoints — where events can be delivered
The two classes you always implement
Microsoft's official naming pattern for business event classes is <NounPhrase><PastTenseAction>BusinessEvent. Examples: VendorInvoicePostedBusinessEvent, PurchaseOrderConfirmedBusinessEvent. The contract class follows the same noun/action pattern with BusinessEventContract as the suffix. Deviating from this makes your events harder to find in the catalog and harder for other developers to identify.
Demo — Custom Business Event: Purchase Order Approved
We will build a complete custom business event that fires when a purchase order moves to Approved status. An external procurement system needs to react in real-time — creating a corresponding record, sending an acknowledgement, or triggering a downstream workflow — without polling D365 F&O.
Step 1 — Build the contract class
The contract class defines exactly what data the external system receives. Every field in the payload is a parm method with [DataMember] and [BusinessEventsDataMember] attributes.
Important rules from Microsoft Learn:
- Never include
RecIdvalues in the payload — use alternate keys instead (PO number, vendor account, etc.) - Convert enum values to their symbol string using
enum2Symbol()before adding to the payload - Use
DateTimeIso8601EDT for datetime fields to get human-readable ISO 8601 format in the JSON payload - The
initialize()method must beprotected— this allows CoC extensions to add fields to your payload - The class must have
[DataContract]attribute and befinal
/// <summary>/// Data contract for the PurchaseOrderApprovedBusinessEvent.
/// Payload sent to external systems when a purchase order is approved in D365 F&O.
/// </summary>
[DataContract]
public final class CSTVendPurchOrderApprovedBusinessEventContract extends BusinessEventsContract
{
private PurchId purchId;
private VendAccount vendorAccount;
private VendName vendorName;
private CurrencyCode currencyCode;
private AmountMST totalAmount;
private str purchaseOrderStatus; // enum converted to symbol
private LegalEntityDataAreaId legalEntity;
private DateTimeIso8601 approvedDateTime; // ISO 8601 for human-readable JSON
// -------------------------------------------------------
// Static constructor — entry point for the base event class
// -------------------------------------------------------
public static CSTVendPurchOrderApprovedBusinessEventContract newFromPurchTable(
PurchTable _purchTable)
{
var contract = new CSTVendPurchOrderApprovedBusinessEventContract();
contract.initialize(_purchTable);
return contract;
}
// -------------------------------------------------------
// Initialize — protected so CoC extensions can add fields
// -------------------------------------------------------
protected void initialize(PurchTable _purchTable)
{
VendTable vendTable = VendTable::find(_purchTable.OrderAccount);
purchId = _purchTable.PurchId;
vendorAccount = _purchTable.OrderAccount;
vendorName = vendTable.name();
currencyCode = _purchTable.CurrencyCode;
totalAmount = _purchTable.calcTotalAmount();
legalEntity = _purchTable.DataAreaId;
approvedDateTime = DateTimeUtil::utcNow();
// Always convert enums to symbol strings for readable JSON
purchaseOrderStatus = enum2Symbol(enumNum(PurchStatus), _purchTable.PurchStatus);
}
private void new() { }
// -------------------------------------------------------
// Parm methods — each maps to a field in the JSON payload
// DataMember = JSON field name visible to consumers
// BusinessEventsDataMember = description in the catalog UI
// -------------------------------------------------------
[DataMember('PurchaseOrderNumber'),
BusinessEventsDataMember('The purchase order number')]
public PurchId parmPurchId(PurchId _purchId = purchId)
{
purchId = _purchId;
return purchId;
}
[DataMember('VendorAccountNumber'),
BusinessEventsDataMember('The vendor account number on the purchase order')]
public VendAccount parmVendorAccount(VendAccount _vendorAccount = vendorAccount)
{
vendorAccount = _vendorAccount;
return vendorAccount;
}
[DataMember('VendorName'),
BusinessEventsDataMember('The vendor name')]
public VendName parmVendorName(VendName _vendorName = vendorName)
{
vendorName = _vendorName;
return vendorName;
}
[DataMember('CurrencyCode'),
BusinessEventsDataMember('The currency code on the purchase order')]
public CurrencyCode parmCurrencyCode(CurrencyCode _currencyCode = currencyCode)
{
currencyCode = _currencyCode;
return currencyCode;
}
[DataMember('TotalAmount'),
BusinessEventsDataMember('The total amount on the purchase order in the order currency')]
public AmountMST parmTotalAmount(AmountMST _totalAmount = totalAmount)
{
totalAmount = _totalAmount;
return totalAmount;
}
[DataMember('PurchaseOrderStatus'),
BusinessEventsDataMember('The current status of the purchase order as a string')]
public str parmPurchaseOrderStatus(str _status = purchaseOrderStatus)
{
purchaseOrderStatus = _status;
return purchaseOrderStatus;
}
[DataMember('LegalEntity'),
BusinessEventsDataMember('The legal entity (company) in which the event occurred')]
public LegalEntityDataAreaId parmLegalEntity(LegalEntityDataAreaId _legalEntity = legalEntity)
{
legalEntity = _legalEntity;
return legalEntity;
}
[DataMember('ApprovedDateTime'),
BusinessEventsDataMember('The UTC date and time when the purchase order was approved (ISO 8601)')]
public DateTimeIso8601 parmApprovedDateTime(DateTimeIso8601 _approvedDateTime = approvedDateTime)
{
approvedDateTime = _approvedDateTime;
return approvedDateTime;
}
}
Step 2 — Build the business event class
The business event class holds the table buffer as private state and delegates payload construction to the contract. The [BusinessEvents] attribute registers the event in the catalog with its contract class, display name, description, and the module it belongs to.
/// <summary>/// Business event raised when a purchase order is approved in D365 F&O.
/// External systems subscribe to this event to react in near-real-time.
/// </summary>
[BusinessEvents(
classStr(CSTVendPurchOrderApprovedBusinessEventContract),
'CST:PurchOrderApprovedBusinessEventName', // label reference — no @ symbol
'CST:PurchOrderApprovedBusinessEventDescription', // label reference — no @ symbol
ModuleAxapta::PurchaseOrder)]
public final class CSTVendPurchOrderApprovedBusinessEvent extends BusinessEventsBase
{
private PurchTable purchTable;
// -------------------------------------------------------
// Private parm method — maintains internal state
// -------------------------------------------------------
private PurchTable parmPurchTable(PurchTable _purchTable = purchTable)
{
purchTable = _purchTable;
return purchTable;
}
// -------------------------------------------------------
// Private constructor — only called from static factory method
// -------------------------------------------------------
private void new()
{
super();
}
// -------------------------------------------------------
// Static factory method — newFrom naming pattern
// -------------------------------------------------------
public static CSTVendPurchOrderApprovedBusinessEvent newFromPurchTable(
PurchTable _purchTable)
{
CSTVendPurchOrderApprovedBusinessEvent businessEvent =
new CSTVendPurchOrderApprovedBusinessEvent();
businessEvent.parmPurchTable(_purchTable);
return businessEvent;
}
// -------------------------------------------------------
// buildContract — called by the framework when the event is active
// [Wrappable(false), Replaceable(false)] prevents CoC on this method
// (extend the CONTRACT class via CoC instead to add payload fields)
// -------------------------------------------------------
[Wrappable(false), Replaceable(false)]
public BusinessEventsContract buildContract()
{
return CSTVendPurchOrderApprovedBusinessEventContract::newFromPurchTable(purchTable);
}
}
buildContract() is decorated with these attributes to prevent Chain of Command wrapping on the event class itself. If you want to add fields to the payload, extend the contract class via CoC instead — that is the correct, upgrade-safe pattern shown in Step 5. Keeping buildContract() non-wrappable enforces this discipline.
Step 3 — Add labels to the label file
The name and description strings in the [BusinessEvents] attribute reference label IDs. Add two labels to your model's label file (e.g. CST.en-US.label.txt):
The [BusinessEvents] attribute takes label IDs as plain strings without the @ prefix. Writing @CST:PurchOrderApprovedBusinessEventName stores a localised string at compile time rather than a label reference. At runtime this produces untranslated or incorrectly localised names in the Business Events catalog. Always omit the @.
Step 4 — Trigger the event from the correct business logic point
Microsoft's guidance is clear: trigger business events at the business logic level, not at the table level. A table-level trigger (DataEventHandler on insert/update) is noisy, lacks business process context, and may fire even when the change comes from a stored procedure or background process where the event should not fire.
The correct place to trigger the PO Approved event is via a Chain of Command extension on the approval method — after the approval is committed but before the transaction closes.
/// <summary>/// CoC extension on PurchTableForm_ApproveOrder to send the business event
/// after a purchase order is approved.
/// </summary>
[ExtensionOf(classStr(PurchTableForm_ApproveOrder))]
public final class CSTVendPurchOrderApprovalTrigger_Extension
{
public void run()
{
// Run the standard approval logic first
next run();
PurchTable purchTable = this.purchTable();
// Re-read to confirm approval succeeded
purchTable.reread();
if (purchTable.PurchStatus == PurchStatus::Backorder
|| purchTable.PurchStatus == PurchStatus::Received)
{
// Only send if the event is active for this legal entity
// This avoids building the payload when nobody is subscribed
if (BusinessEventsConfigurationReader::isBusinessEventEnabled(
classStr(CSTVendPurchOrderApprovedBusinessEvent)))
{
CSTVendPurchOrderApprovedBusinessEvent
::newFromPurchTable(purchTable)
.send();
}
}
}
}
BusinessEventsConfigurationReader::isBusinessEventEnabled(classStr(YourEvent)) checks whether the event is active in the Business Events catalog for the current legal entity. For lightweight payloads, you can call .send() unconditionally — the framework skips payload building internally if the event is inactive. But if your initialize() method performs additional queries or calculations, wrap the entire block in an isBusinessEventEnabled() check to avoid the cost when no one is subscribed.
Step 5 — Rebuild the business event catalog
After building and deploying your package, the new event must be registered in the Business Events catalog before it can be activated or subscribed to.
- In D365 F&O, navigate to System Administration → Business Events → Business Events Catalog
- Click Rebuild catalog
- Your event appears in the catalog under the PurchaseOrder category with the name and description from your labels
- Click the event → Activate → select the legal entities to activate it for
The catalog is not rebuilt automatically on package deployment. If your event does not appear in the catalog after deploying, the rebuild was not run. Similarly, if you change the labels or description of an existing event, rebuild is required for the changes to appear in the UI.
What the payload looks like
When the event fires and is delivered to an endpoint, the external system receives a JSON payload. Here is what the payload from our CSTVendPurchOrderApprovedBusinessEvent looks like:
Note "PurchaseOrderStatus": "Backorder" — this is the symbol string from enum2Symbol(), not the integer enum value. Human-readable enums in the payload are essential for consumers that do not have knowledge of D365 F&O enum values. Also note "ApprovedDateTime" in ISO 8601 format — a direct result of using the DateTimeIso8601 EDT type.
Extending a standard business event payload
You may need to add custom fields to an existing out-of-box business event — for example, adding a custom field to the sales invoice posted event payload. This is done entirely through CoC without touching the standard class.
The example below extends CustFreeTextInvoicePostedBusinessEventContract to add a custom customer classification field — taken directly from the Microsoft Learn developer documentation.
/// Step 1 — Extend the contract class state
[ExtensionOf(classStr(CustFreeTextInvoicePostedBusinessEventContract))]
internal final class CSTCustFreeTextInvoicePostedBEC_Extension
{
// Private state added to the contract
private str customerClassification;
/// Step 2 — Extend initialize() via CoC to populate the new field
protected void initialize(CustInvoiceJour _custInvoiceJour)
{
// Always call next first
next initialize(_custInvoiceJour);
// Populate your custom field from the invoice journal or related records
CustTable custTable = CustTable::find(_custInvoiceJour.InvoiceAccount);
customerClassification = custTable.CSTCustomerClassification; // custom extension field
}
/// Step 3 — Add a parm method so the field appears in the JSON payload
[DataMember('CSTCustomerClassification'),
BusinessEventsDataMember('Custom customer classification for reporting')]
public str parmCustomerClassification(str _classification = customerClassification)
{
customerClassification = _classification;
return customerClassification;
}
}
After rebuilding the catalog and reactivating the event, the payload now includes "CSTCustomerClassification" alongside all the standard fields — with no changes to the standard classes.
Connecting to Power Automate
Power Automate is the easiest endpoint for teams that do not have Azure infrastructure already set up. The D365 F&O connector in Power Automate has a native trigger for business events.
Setup steps in Power Automate
- In Power Automate, create a new Automated cloud flow
- Search for trigger: "When a Business Event occurs (Finance and Operations)"
- Configure the trigger:
- Instance: your D365 F&O environment URL
- Category: PurchaseOrder
- Business Event: select
CSTVendPurchOrderApprovedBusinessEventfrom the dropdown (it appears after catalog rebuild and activation) - Legal Entity: USMF (or the entity you activated the event for)
- Add your flow actions — for example:
- Parse the JSON payload using the schema from your contract
- Send an approval email to the procurement manager
- Create a record in an external system via HTTP connector
- Post a message to a Teams channel
The trigger body contains the full JSON payload from your contract. Use Parse JSON with the schema matching your DataMember field names to access individual values like PurchaseOrderNumber, VendorName, and TotalAmount as dynamic content in subsequent steps.
Connecting to Azure Logic Apps via Service Bus
For enterprise integration scenarios — especially where you need filtering, dead-letter queues, or fan-out to multiple subscribers — Azure Service Bus is the recommended endpoint. The pattern is:
Setup steps in D365 F&O — Service Bus endpoint
- Create a Service Bus namespace in Azure, with a Topic and at least one Subscription
- Generate a Shared Access Policy with Send claims — copy the connection string
- In D365 F&O: System Administration → Business Events → Endpoints → New
- Select endpoint type: Azure Service Bus
- Enter:
- Endpoint name: ProcurementServiceBus
- Service Bus connection string (from the SAS policy)
- Topic name
- In the Business Events catalog, activate
CSTVendPurchOrderApprovedBusinessEventand assign this endpoint
Logic App trigger — Service Bus
In Azure Logic Apps, add a trigger: "When a message is received in a topic subscription (peek-lock)". Use peek-lock rather than auto-complete — this lets the Logic App complete or abandon the message based on whether the downstream action succeeded. Parse the message body as JSON using your contract schema to access individual payload fields.
Error handling and retry behaviour
Business Events do not guarantee exactly-once delivery. Network retries, framework retries, and endpoint redelivery can all result in the same event arriving at your consumer more than once. Always use the EventId field in the payload as an idempotency key — check whether you have already processed this event before taking any action. This is especially important for events that trigger financial transactions or data writes in external systems.
When to use Business Events vs other integration patterns
This is the most common misuse of the framework. If your intent is to transfer a full dataset to an external system — exporting all customers, syncing a product catalogue, replicating transaction history — Business Events are the wrong tool. They are designed for event notification, not data replication. Use DMF recurring exports or OData batch reads for data transfer scenarios.
Pitfalls summary
Using [DataEventHandler(tableStr(PurchTable), DataEventType::Updated)] as the trigger fires on every update to the table — including background processes, data migrations, and batch jobs. This is noisy, loses business process context, and may miss events from stored procedures. Always trigger from the business logic method where the process completes.
RecId values are internal database identifiers that have no meaning outside the D365 F&O environment. External systems cannot use them and they change during data migration. Always use alternate keys — PurchId, SalesId, AccountNum, VendorAccountNumber — in your payload.
The Business Events catalog does not update automatically when you deploy a new package containing a business event class. You must manually navigate to the catalog and click Rebuild catalog after every deployment that adds or modifies a business event.
The name and description parameters in [BusinessEvents(...)] must be label IDs without the @ prefix. Using @CST:MyLabel resolves the label at compile time and stores the English string, breaking localisation and producing hardcoded text in the catalog.
Assigning an enum value directly to a str field in your contract stores the integer representation (e.g. 2 instead of "Backorder"). External systems receiving "PurchaseOrderStatus": 2 have no way to interpret this without D365 F&O enum metadata. Always use enum2Symbol(enumNum(YourEnum), enumValue) to convert before assigning.
Conclusion :-
The Business Events framework is one of the cleanest integration patterns in D365 F&O — it is push-based, transactionally guaranteed, security-aware, and requires no polling infrastructure. Once you understand that it consists of exactly two classes (the event and the contract), a trigger placed at the right point in the business logic, and a catalog registration step, the implementation is straightforward.
The patterns that matter most in production are: triggering at the business logic level, not the table level; using alternate keys in the payload; guarding expensive payload logic with isBusinessEventEnabled(); and designing consumers to be idempotent against duplicate delivery.
For teams using Power Automate, the native D365 F&O connector makes consumption trivial. For enterprise scenarios requiring filtering, fan-out, or dead-letter handling, Azure Service Bus is the right endpoint. Either way, the X++ implementation is identical — the payload arrives the same way regardless of where it goes.