Codeunit API’s in Business Central

5 Mar

This blog post was on my list way too long… But now I found some time to sit down and write it.

Disclaimer

What I’m going to show here is officially not supported (yet). It is an undocumented feature that already exists for a couple of years. I believe it can even be used in Dynamics NAV 2018 and maybe earlier versions as well. In fact, Microsoft uses this feature themselves in the Power Automate Flow connector for approvals. So it is a feature that goes undocumented and officially unsupported, but I wouldn’t expect it to go away. Instead, I hope it is going to be turned into an officially supported feature.

UPDATE 07-05-2020:
Microsoft recently announced that this is now an officially supported feature! For more information see: https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-creating-and-interacting-with-odatav4-unbound-action

As a matter of fact, the title of this blog post should be something like ‘Unbound actions with Codeunit web services in Business Central’. But I’m not sure if everybody would immediately recognize what it is about.

Bound vs. Unbound Actions

As you may know, it is possible to define actions on API pages that can be called with a restful API call. For example, you can call Post on a Sales Invoice like this:

post https://api.businesscentral.dynamics.com/v2.0/{environment}/api/v1.0/companies({id})/salesinvoices({id}})/Microsoft.NAV.Post
Authorization: Bearer {token}
Content-Type: application/json

This function Post is available on the API page for Sales Invoices and it looks like this:

[ServiceEnabled]
[Scope('Cloud')]
procedure Post(var ActionContext: WebServiceActionContext)
var
    SalesHeader: Record "Sales Header";
    SalesInvoiceHeader: Record "Sales Invoice Header";
    SalesInvoiceAggregator: Codeunit "Sales Invoice Aggregator";
begin
    GetDraftInvoice(SalesHeader);
    PostInvoice(SalesHeader, SalesInvoiceHeader);
    SetActionResponse(ActionContext, SalesInvoiceAggregator.GetSalesInvoiceHeaderId(SalesInvoiceHeader));
end;

What is important here, that this function is called a ‘bound action’ because it is bound to an existing entity, in this case, a Sales Invoice.

But what if you want to call a function in a Codeunit with an API call? That is possible by publishing the Codeunit as a web service and call it with a SOAP web service call. Would it also be possible to do that with a restful API call, like the API pages? And the answer to that is, yes, that is possible! The web services page doesn’t show you an ODataV4 URL for a published Codeunit, but it actually is possible to call the Codeunit with an ODataV4 URL. That is called ‘unbound actions’. Calling a Codeunit is not bound to any entity at all. Not even to the company, which is normally the first entity you specify in the ODataV4 or API URL.

Simple Example of an Unbound Action

Let’s create a simple Codeunit and publish it as a web service.

codeunit 50100 "My Unbound Action API"
{
    procedure Ping(): Text
    begin
        exit('Pong');
    end;
}

We can’t publish a Codeunit as an API, the only possibility is to publish it as a web service. For that, we add this XML file to the app:

<?xml version="1.0" encoding="UTF-8"?>
<ExportedData>
    <TenantWebServiceCollection>
        <TenantWebService>
            <ObjectType>CodeUnit</ObjectType>
            <ObjectID>50100</ObjectID>
            <ServiceName>MyUnboundActions</ServiceName>
            <Published>true</Published>
        </TenantWebService>
    </TenantWebServiceCollection>
</ExportedData>

After installation, the web service is available. But the ODataV4 URL is not applicable according to this page.

web services list

Let’s just ignore that and call the web service with the ODataV4 url nonetheless. I’m using the VS Code extension Rest Client for this. As you can see, the URL is build up as the normal ODataV4 url, but it ends with MyUnboundActions_Ping. The name of the function is composed as follows: /[service name]_[function name]

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_Ping
Authorization: Basic {{username}} {{password}}

The result of this call (response headers removed for brevity):

HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String",
  "value": "Pong"
}

Isn’t that cool? We can publish Codeunits as web service and still use restful API calls to invoke them, instead of using SOAP!

Reading data

What about using data? Let’s try another example and see what happens. I’ve added another function that simply reads the first record of the Customer table. Since we haven’t specified any company, what would happen?

codeunit 50100 "My Unbound Action API"
{
    procedure Ping(): Text
    begin
        exit('Pong');
    end;
    procedure GetFirstCustomerName(): Text
    var
        Cust: Record Customer;
    begin
        Cust.FindFirst();
        exit(Cust.Name);
    end;
}

The call to the web service looks like this:

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_GetFirstCustomerName
Authorization: Basic {{username}} {{password}}

And the result of this call is an error:

HTTP/1.1 400 You must choose a company before you can access the "Customer" table.
{
  "error": {
    "code": "Internal_ServerError",
    "message": "You must choose a company before you can access the \"Customer\" table.  CorrelationId:  7b627296-5aca-4e4a-8e46-9d54f199b702."
  }
}

Obviously, we need to specify a company. Let’s try to do that by specifying the company in the url:

post https://bcsandbox.docker.local:7048/BC/ODataV4/Company('72e17ce1-664e-ea11-bb30-000d3a256c69')/MyUnboundActions_GetFirstCustomerName
Authorization: Basic {{username}} {{password}}

However, we still get an error:

HTTP/1.1 404 No HTTP resource was found that matches the request URI 'https://bcsandbox.docker.local:7048/BC/ODataV4/Company(%2772e17ce1-664e-ea11-bb30-000d3a256c69%27)/MyUnboundActions_GetFirstCustomerName'.
{
  "error": {
    "code": "BadRequest_NotFound",
    "message": "No HTTP resource was found that matches the request URI 'https://bcsandbox.docker.local:7048/BC/ODataV4/Company(%2772e17ce1-664e-ea11-bb30-000d3a256c69%27)/MyUnboundActions_GetFirstCustomerName'.  CorrelationId:  04668a8d-1f2b-4e1e-aebe-883886e8fa2b."
  }
}

What is going on? An OData url points to an entity. Every entity has its own unique url. But the Codeunit function is not bound to any entity, like an Item, Customer, Sales Order, etc. That’s why it is called an unbound action. But if the company was part of the url, then it is bound to the company entity and not considered to be an unbound action anymore. This is simply due to the fact that Business Central works with multiple companies in one database. If that was just one company, then you wouldn’t have the company in the url and the unbound action would work.

Instead of adding the company as an entity component to the url, it is possible to add a company query parameter. Then the call looks like this:

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_GetFirstCustomerName?company=72e17ce1-664e-ea11-bb30-000d3a256c69
Authorization: Basic {{username}} {{password}}

And this works:

HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String",
  "value": "Adatum Corporation"
}

Alternatively, you can also add the company as a header instead of a query parameter:

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_GetFirstCustomerName
Authorization: Basic {{username}} {{password}}
Company: 72e17ce1-664e-ea11-bb30-000d3a256c69

As you can see, we can use the company id instead of the company name. To get the company id, you can use this call (notice the get instead of post):

get https://bcsandbox.docker.local:7048/BC/ODataV4/Company
Authorization: Basic {{username}} {{password}}

And use the id from the response.

HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Company",
  "value": [
    {
      "Name": "CRONUS USA, Inc.",
      "Evaluation_Company": true,
      "Display_Name": "",
      "Id": "72e17ce1-664e-ea11-bb30-000d3a256c69",
      "Business_Profile_Id": ""
    },
    {
      "Name": "My Company",
      "Evaluation_Company": false,
      "Display_Name": "",
      "Id": "084479f8-664e-ea11-bb30-000d3a256c69",
      "Business_Profile_Id": ""
    }
  ]
}

Using Parameters

What about passing in parameters? Well, that’s also possible. As you may have seen, all calls the to unbound actions use the HTTP POST command. That means we are sending data. So far, the demo didn’t do that. Let’s do that in the next demo. I have added a function Capitalize with a text input parameter.

codeunit 50100 "My Unbound Action API"
{
    procedure Ping(): Text
    begin
        exit('Pong');
    end;
    procedure GetFirstCustomerName(): Text
    var
        Cust: Record Customer;
    begin
        Cust.FindFirst();
        exit(Cust.Name);
    end;
    procedure Capitalize(input: Text): Text
    begin
        exit(input.ToUpper);
    end;
}

To add the parameter data to the call, we need to add content. Don’t forget to set the header Content-Type!

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_Capitalize
Authorization: Basic {{username}} {{password}}
Content-Type: application/json
{
    "input": "business central rocks!"
}

And here is the result of this call:

HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String",
  "value": "BUSINESS CENTRAL ROCKS!"
}

Be careful with capitals in parameter names! The first character must be lower case. Even when you use uppercase, it will be corrected. If you use uppercase in the call, then you might see this error message:

HTTP/1.1 400 Exception of type 'Microsoft.Dynamics.Nav.Service.OData.NavODataBadRequestException' was thrown.
{
  "error": {
    "code": "BadRequest",
    "message": "Exception of type 'Microsoft.Dynamics.Nav.Service.OData.NavODataBadRequestException' was thrown.  CorrelationId:  e0003c52-0159-4cf5-974d-312ef4729c56."
  }
}

Return Types

So far, the demo’s only returned text types. What happens if we return a different type, like an integer, a boolean or datetime? Here you have some examples:

codeunit 50100 "My Unbound Action API"
{
    procedure Ping(): Text
    begin
        exit('Pong');
    end;
    procedure GetFirstCustomerName(): Text
    var
        Cust: Record Customer;
    begin
        Cust.FindFirst();
        exit(Cust.Name);
    end;
    procedure Capitalize(input: Text): Text
    begin
        exit(input.ToUpper);
    end;
    procedure ItemExists(itemNo: Text): Boolean
    var
        Item: Record Item;
    begin
        Item.SetRange("No.", itemNo);
        exit(not item.IsEmpty());
    end;
    procedure GetCurrentDateTime(): DateTime
    begin
        exit(CurrentDateTime());
    end;
}

Functions ItemExists and GetCurrentDateTime are added to the Codeunit.

The call to ItemExists and the result:

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_ItemExists
Authorization: Basic {{username}} {{password}}
Content-Type: application/json
Company: 72e17ce1-664e-ea11-bb30-000d3a256c69
{
    "itemNo": "1896-S"
}
HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.Boolean",
  "value": true
}

And this is how the call to GetCurrentDateTime and the response looks like:

post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_GetCurrentDateTime
Authorization: Basic {{username}} {{password}}
Content-Type: application/json
HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.DateTimeOffset",
  "value": "2020-03-02T15:13:39.49Z"
}

What about return complex types, like a Json payload? Unfortunately, that doesn’t work as you would like:

codeunit 50100 "My Unbound Action API"
{
    procedure Ping(): Text
    begin
        exit('Pong');
    end;
    procedure GetFirstCustomerName(): Text
    var
        Cust: Record Customer;
    begin
        Cust.FindFirst();
        exit(Cust.Name);
    end;
    procedure Capitalize(input: Text): Text
    begin
        exit(input.ToUpper);
    end;
    procedure ItemExists(itemNo: Text): Boolean
    var
        Item: Record Item;
    begin
        Item.SetRange("No.", itemNo);
        exit(not item.IsEmpty());
    end;
    procedure GetCurrentDateTime(): DateTime
    begin
        exit(CurrentDateTime());
    end;
    procedure GetJsonData() ReturnValue: Text
    var
        Jobj: JsonObject;
    begin
        JObj.Add('key', 'value');
        Jobj.WriteTo(ReturnValue);
    end;
}
post https://bcsandbox.docker.local:7048/BC/ODataV4/MyUnboundActions_GetJsonData
Authorization: Basic {{username}} {{password}}
Content-Type: application/json
HTTP/1.1 200 OK
{
  "@odata.context": "https://bcsandbox.docker.local:7048/BC/ODataV4/$metadata#Edm.String",
  "value": "{\"key\":\"value\"}"
}

The data is formatted as a Json text value instead of a real Json structure. And if you try to change the function to return a JsonObject rather than a text variable, then the whole web service is not valid anymore as a web service and you will not be able to call it. For this to work, we need an option to define custom entities and add it to the metadata. It would be great if Microsoft would enable this!

Support in the cloud

All these demos were on my local docker environment. But this works exactly the same on the cloud platform. Just change the url and it will work like a charm:

For basic authentication you need the use this url and specify your tenant:

post https://api.businesscentral.dynamics.com/v2.0/{tenantid}/{environment}/ODataV4/MyUnboundActions_Ping
Authorization: Basic {{username}} {{password}}

For example, when I use the sandbox environment on my tenant, I can replace {tenantid} with kauffmann.nl and {environment} with sandbox:

post https://api.businesscentral.dynamics.com/v2.0/kauffmann.nl/sandbox/ODataV4/MyUnboundActions_Ping
Authorization: Basic {{username}} {{password}}

For OAuth and production environments, you should use this url (no tenant id needed):

post https://api.businesscentral.dynamics.com/v2.0/{environment}/ODataV4/MyUnboundActions_Ping
Authorization: Bearer {token}

Use the ODataV4 and not the API endpoint

Remember that this only works with the ODataV4 endpoint and not with the API endpoint. You need to publish the Codeunit as a web service first. To get this on the API endpoint, it should also implement namespaces and versioning as we know it in the API pages. Versioning is a key feature, as it allows us to implement versioned contracts. And personally, I wouldn’t mind if Microsoft also removes the word NAV from both bound and unbound actions. Update 07-05-2020: The word NAV is not needed anymore for unbound actions. Bound actions still need it.

That’s it! Hope you enjoyed it! Based on my conversations with Microsoft, I know that this topic is something they are discussing for the future. What do you think, should this be turned into a Codeunit type API or is it useless and can we stick with Page and Query API’s? Let me know in the comments!

15 thoughts on “Codeunit API’s in Business Central

    • I think Microsoft wasn’t sure if they would keep it or not. Maybe they were thinking for a moment that Page and Query API’s would he sufficient.

    • That’s what I said, I wouldn’t mind if they change it. But it’s a breaking change, so I don’t see that happening anytime soon.

  1. Great post!

    For reference, I’d like to point out that Microsoft did mention this way back at NAVTechDays 2017, though this isn’t mentioned in the documentation:
    https://www.youtube.com/watch?v=d9jMAnYB6qk&feature=youtu.be&t=2450&fbclid=IwAR31svAqvyX1cC9Abj9w9qosgXDHAURpyAQInSfEIVQ7hjIq0ioaV8jXzVk

    In the EDMX document ($metadata) you can see the action definitions, in chrome you can search for “<Action " to see more information about them, like IsBound, Parameters and the ReturnType.

    I think Nikola has twice mentioned on yammer that this may be discontinued in the future, though I can't see the motivation for removing the feature.

    • You are definitely right. That presentation was the first time I saw it. And over time I’ve had several discussions with Microsoft to convince them that this is a feature we want to keep.
      So this still goes undocumented and not officially supported, but I’ve now strong feelings that it is not going away.

  2. Very nice article indeed.
    Just a question, i need to return json object from bound function, is it possible to return a json object from bound function. Because the size of my returned object will be more than text max size.

    • No, bound actions don’t return data. Instead, they can return a location header with the URI to the updated record. Like the posted invoice, or copied item record, etc.
      Unbound actions can return data, but not real JSON objects (for now).

      If the returned object doesn’t fit the max text size, then you have really big data. I would consider other options in that case.

  3. Hi, Even though MS support this, in the Web services page the ODataV4 URL is not applicable. Do you know if there is special settings for this?

    • No, no special setting. Every function inside the Codeunit gets its own URL, so it would not even be possible to show just one ODataV4 URL on the page.

  4. Hi Kauffman ,

    I have publised a code unit a webservice on cloud and trying to call odata from c# project but getting not found error from post also.

    • did you check the metadata, is your bound functions displaying in the metadata list.

    • You may have an unsupported parameter, then the whole codeunit will not be available. Would you mind to share the codeunit and the URL you are using to call it? You may send it to my email: aj@kauffmann.nl

  5. Hello, at a point you said we will add the xml file to the app. I am a bit confused, just create a file and add the wsdl you wrote to it and publish the extension?

    • The xml file I mentioned is about the possibility to expose webservices from the app by means of an xml definition. You don’t add the wsdl there, the xml definition is as described in my post.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.