How to Build a Custom REST Web Service (JSON API) in D365 FO

Web service in D365 FO using x++


When connecting Dynamics 365 Finance & Operations with third-party systems or middleware like Azure Logic Apps, Custom Services are often the cleanest approach. They allow us to expose custom X++ logic directly as REST/JSON endpoints with built-in OAuth support.

In this post, I will walk through a step-by-step implementation for building an inbound Custom Web Service to create Vendor Rebate Deals.


Component Breakdown

To build a robust web service in D365 FO, we need four core pieces:

  1. Request Data Contract: Maps incoming JSON fields to X++ variables.

  2. Response Data Contract: Returns execution status, messages, and record IDs back to the caller.

  3. Service Class: Handles business logic, company switching (changecompany), and transaction rollback.

  4. Service & Service Group: Exposes the X++ method in the AOT as an HTTP endpoint.


Step 1: Create Response Data Contract

First, define what the service returns. It is good practice to return a status indicator (Success or Error), any infolog error messages, and the primary key of the created record.

/// <summary>

/// Response contract for Vendor Rebate processing

/// </summary>

[DataContractAttribute]

public class GUPVendorRebateResponse

{

    private str status;

    private str message;

    private TAMRebateDealId rebateDealId;


    [DataMemberAttribute('Status')]

    public str parmStatus(str _status = status)

    {

        status = _status;

        return status;

    }


    [DataMemberAttribute('Message')]

    public str parmMessage(str _message = message)

    {

        message = _message;

        return message;

    }


    [DataMemberAttribute('RebateDealId')]

    public TAMRebateDealId parmRebateDealId(TAMRebateDealId _rebateDealId = rebateDealId)

    {

        rebateDealId = _rebateDealId;

        return rebateDealId;

    }


    public static GUPVendorRebateResponse newFromParameters(str _status, str _message, TAMRebateDealId _rebateDealId)

    {

        GUPVendorRebateResponse response = new GUPVendorRebateResponse();

        response.parmStatus(_status);

        response.parmMessage(_message);

        response.parmRebateDealId(_rebateDealId);

        return response;

    }

}


Step 2: Create Request Data Contracts

Next, build the contracts to parse the inbound JSON payload. We will use a main request contract to capture the target DataAreaId (Legal Entity) and a child contract for deal details.

[DataContractAttribute]

public class GUPVendorRebateDealContract

{

    private VendAccount vendAccount;

    private TAMRebateCurrencyCode currencyCode;

    private TAMRebateFromDate fromDate;

    private TAMRebateToDate toDate;


    [DataMemberAttribute('VendAccount')]

    public VendAccount parmVendAccount(VendAccount _vendAccount = vendAccount)

    {

        vendAccount = _vendAccount;

        return vendAccount;

    }


    [DataMemberAttribute('CurrencyCode')]

    public TAMRebateCurrencyCode parmCurrencyCode(TAMRebateCurrencyCode _currencyCode = currencyCode)

    {

        currencyCode = _currencyCode;

        return currencyCode;

    }


    [DataMemberAttribute('FromDate')]

    public TAMRebateFromDate parmFromDate(TAMRebateFromDate _fromDate = fromDate)

    {

        fromDate = _fromDate;

        return fromDate;

    }


    [DataMemberAttribute('ToDate')]

    public TAMRebateToDate parmToDate(TAMRebateToDate _toDate = toDate)

    {

        toDate = _toDate;

        return toDate;

    }

}


Parent Request Contract

[DataContractAttribute]

public final class GUPVendorRebateRequest

{

    private DataAreaId dataAreaId;

    private GUPVendorRebateDealContract dealContract;


    [DataMemberAttribute('DataAreaId')]

    public DataAreaId parmDataAreaId(DataAreaId _dataAreaId = dataAreaId)

    {

        if (!prmIsDefault(_dataAreaId))

        {

            dataAreaId = _dataAreaId;

        }

        return dataAreaId;

    }


    [DataMemberAttribute('DealContract')]

    public GUPVendorRebateDealContract parmDealContract(GUPVendorRebateDealContract _dealContract = dealContract)

    {

        if (!prmIsDefault(_dealContract))

        {

            dealContract = _dealContract;

        }

        return dealContract;

    }

}


Step 3: Implement Service Class

Now create the class containing the business logic. Notice two critical features here:

  1. changecompany() block: Ensures data is written to the legal entity requested in JSON, regardless of the integration user's default company.

  2. infolog.cut() helper: Captures internal infolog errors so callers get actionable error details if validation fails.

public final class GUPVendorRebateService
{
    /// <summary>
    /// Service entry point to create a Vendor Rebate Deal
    /// </summary>
    public GUPVendorRebateResponse createVendorRebate(GUPVendorRebateRequest _request)
    {
        TAMRebateDeal rebateDeal;
        str status = "Success";
        str message;

        if (!_request.parmDataAreaId())
        {
            return GUPVendorRebateResponse::newFromParameters("Error", "DataAreaId must be provided in the request.", "");
        }

        changecompany(_request.parmDataAreaId())
        {
            try
            {
                ttsbegin;

                GUPVendorRebateDealContract contract = _request.parmDealContract();

                if (!contract.parmVendAccount())
                {
                    throw Error("@RebatesAndDeductions:FieldMustBeSpecified");
                }

                // Map fields to standard table
                rebateDeal.RebateCompanyId    = _request.parmDataAreaId();
                rebateDeal.Deal               = NumberSeq::newGetNum(TAMRebateParameters::numRefRebateDealId(), false).num();
                rebateDeal.VendAccount        = contract.parmVendAccount();
                rebateDeal.RebateCurrencyCode = contract.parmCurrencyCode();
                rebateDeal.RebateFromDate     = contract.parmFromDate();
                rebateDeal.RebateToDate       = contract.parmToDate();
                rebateDeal.insert();

                ttscommit;

                message = strFmt("Rebate deal %1 created successfully.", rebateDeal.Deal);
            }
            catch (Exception::CLRError)
            {
                ttsabort;
                status  = "Error";
                message = CLRInterop::getLastException() ? CLRInterop::getLastException().ToString() : this.getInfologError();
            }
            catch
            {
                ttsabort;
                status  = "Error";
                message = this.getInfologError();
            }
        }

        return GUPVendorRebateResponse::newFromParameters(status, message, rebateDeal.Deal);
    }

    /// <summary>
    /// Helper method to extract messages from the Infolog
    /// </summary>
    private str getInfologError()
    {
        SysInfologEnumerator enumerator = SysInfologEnumerator::newData(infolog.cut());
        str errorMsg;

        while (enumerator.moveNext())
        {
            SysInfologMessageStruct msgStruct = new SysInfologMessageStruct(enumerator.currentMessage());
            errorMsg = strFmt("%1 %2", errorMsg, msgStruct.message());
        }
        return errorMsg;
    }
}

Step 4: Expose Service via AOT

  1. Create Service Node:

    • Right-click your Visual Studio project > Add > New Item > Security & Services > Service.

    • Set Name to GUPVendorRebateService.

    • Set Class property to GUPVendorRebateService.

    • Under Service Operations, add a new operation and select the createVendorRebate method.

  2. Create Service Group:

    • Right-click project > Add > New Item > Security & Services > Service Group.

    • Set Name to GUPVendorServices.

    • Drag your service node into this group.

    • Set AutoDeploy to Yes.


Step 5: Testing with Postman

Once deployed, the endpoint is immediately available via OAuth 2.0 authentication.

  • URL Format: https://<D365-URL>/api/services/GUPVendorServices/GUPVendorRebateService/createVendorRebate

  • Method: POST

  • Headers:

    • Authorization: Bearer <AccessToken>

    • Content-Type: application/json


Request Payload

{
  "_request": {
    "DataAreaId": "USMF",
    "DealContract": {
      "VendAccount": "V10001",
      "CurrencyCode": "USD",
      "FromDate": "2026-01-01",
      "ToDate": "2026-12-31"
    }
  }
}

Response
{
  "Status": "Success",
  "Message": "Rebate deal RBT-000452 created successfully.",
  "RebateDealId": "RBT-000452"
}


Comments