Batch calls with Business Central APIs (3) – Tips and Tricks

6 Jan

This is the third post in a series about batch calls with Business Central. If you haven’t read the the other posts, then I recommend to do so. You’ll find the first post about basic operations with batch calls here. The second post about error handling and transactions can be found here. This third and final post will cover these topics:

  • Reduce the size of the response payload
  • Combine multiple operation types, like POST and GET
  • Define the order of operations
  • Batch calls with amounts running into an error or timeout

Reduce the size of the response payload

This tip works for both batch calls and normal API calls. As you have seen in the first post about basic operations, the response of the batch call contains a response for every single operation. What if you don’t do anything with this information? Maybe the only thing you want to know is if the operations was successful, but you don’t really use the returned data.

An example could be when you create multiple sales order lines, but in the end you want to retrieve the full sales order with header and lines, instead of getting back each individual line. Or maybe you want to post the sales order after creating the lines and then retrieve the posted invoice.

Another scenario could be that you create multiple records, but you don’t want to get the full details in the result. Only the created id and number could be sufficient.

Not returning the full data of the individual operations greatly reduces the JSON payload that goes over the wire and has a positive effect on the performance.

Let’s first look at only retrieving those details we are interested in. In the following example, three items are being created and we only want to get back the created number (from the number series) and the id.

POST {{baseurl}}/api/v2.0/$batch
Content-Type: application/json
Accept: application/json
{
	"requests": [
		{
			"method": "POST",
		    "id": "1",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
                "displayName" : "Item 1"
			}
		},
		{
			"method": "POST",
			"id": "2",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
                "displayName" : "Item 2"
	        }
		},
        {
			"method": "POST",
			"id": "3",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
                "displayName" : "Item 3"
	        }
		}
    ]
}

As you can see, the URLs of the operations have the parameter $select=id,number. Compare this to a SQL SELECT query. If you don’t use $select, you are in fact saying SELECT * FROM. But with $select, you retrieve a reduced dataset of only the fields you are interested in. That works not only for the GET command, but also for the POST command.

The result of the batch call looks like:

{
    "responses": [
        {
            "id": "1",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(356f53bb-874f-eb11-a856-8ee7d7617d9e)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items/$entity",
                "@odata.etag": "W/\"JzQ0O3kzdGdHUEgzZThHU3lTZU5BVlNjM3JXR0lFSnNGWE1uQTh2MmczRU1WcVU9MTswMDsn\"",
                "id": "356f53bb-874f-eb11-a856-8ee7d7617d9e",
                "number": "1001"
            }
        },
        {
            "id": "2",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(376f53bb-874f-eb11-a856-8ee7d7617d9e)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items/$entity",
                "@odata.etag": "W/\"JzQ0O0VCRUZGRlFuS1VWb0xGUFl2NkhSQmowUi9PUlBqMC9sdG96UnRTekNuRVE9MTswMDsn\"",
                "id": "376f53bb-874f-eb11-a856-8ee7d7617d9e",
                "number": "1002"
            }
        },
        {
            "id": "3",
            "status": 201,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(396f53bb-874f-eb11-a856-8ee7d7617d9e)",
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "@odata.context": "https://bcsandbox.docker.local:7048/bc/api/v2.0/$metadata#companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items/$entity",
                "@odata.etag": "W/\"JzQ0OzBCdG1WRVA2NGpqZEJaSjJiaW5VYkNnbXhJYTZ1ckphdGdUMWdvOFNBalE9MTswMDsn\"",
                "id": "396f53bb-874f-eb11-a856-8ee7d7617d9e",
                "number": "1003"
            }
        }
    ]
}

What if you are not even interested in a single field, but only want to see the status? The solution is easy: add a header Prefer: return-no-content to the operation which tells the server to not include a body. This header works also on direct calls!

{
	"requests": [
		{
			"method": "POST",
		    "id": "1",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
                "displayName" : "Item 1"
			}
		},
		{
			"method": "POST",
			"id": "2",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
                "displayName" : "Item 2"
	        }
		},
        {
			"method": "POST",
			"id": "3",
			"url": "companies({{companyId}})/items?$select=id,number",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
                "displayName" : "Item 3"
	        }
		}
    ]
}

The result now looks like this:

{
    "responses": [
        {
            "id": "1",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(83830e14-8a4f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "2",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(85830e14-8a4f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "3",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/items(87830e14-8a4f-eb11-a856-8ee7d7617d9e)"
            }
        }
    ]
}

As you can see, the bodies of the operations are missing, and instead a location header has been added with a direct URL of the created record. Also notice that the status of the operation is 204 No Content, not 201 Created as with the previous example. It still means OK, because it is a 2xx status code. If there was an error, then the status will have the usual 400 status code and the body will still include the error message, ignoring the no content preference.

Combine multiple operation types

A batch call doesn’t have to consist of only similar operations to the same API or of the same type. Each operation can be be for a different API. For example, it is no problem to create a new customers, vendors, items, etc., all in one batch. The operations also don’t have to be of the same type, it is perfectly possible to have any combination of POST, GET, PATCH and DELETE operations in one batch. Some scenarios where that might be useful are (spoiler alert: but not all of these are possible):

  • Create a new customer and a new sales order for this new customer
  • Create a new sales order, post it and retrieve the created invoice
  • Create multiple journal lines and post them

The last scenario is the most simple one. But before we look at the batch request for this scenario, we have to think about a potential problem. Although it seems that all operations in a batch request are executed one after another, that does not mean they will wait for each other to complete. The operations may be processed in parallel. But you don’t want the posting of the journal lines to start before the creation of the lines has been completed. That’s where the property dependsOn is for. With this property you can define that an operation in the batch should not start before the other operations, as defined with the dependsOn property, are successfully completed.

Let’s look at the body of this batch request and look at the dependsOn property of the last operation with id 4. It tells that operations 1, 2 and 3 must be completed first. Also note that the Prefer: return-no-content header is used to reduce the resulting payload.

{
	"requests": [
		{
			"method": "POST",
		    "id": "1",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3250,
			    "description": "Salary to Bob"
			}
		},
		{
			"method": "POST",
			"id": "2",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3500,
			    "description": "Salary to John"
	        }
		},
        {
			"method": "POST",
			"id": "3",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json",
                "Prefer": "return-no-content"
			},
			"body": {
			    "accountId": "{{accountId2}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": 6750,
			    "description": "Salaries December 2020"
	        }
		},
        {
            "method": "POST",
			"id": "4",
            "dependsOn": ["1","2","3"],
			"url": "companies({{companyId}})/journals({{journalId}})/Microsoft.NAV.post",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": { }
        }
    ]
}

The result looks like:

{
    "responses": [
        {
            "id": "1",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/journals(875626ff-de49-eb11-bb51-000d3a25738b)/journalLines(116001c3-994f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "2",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/journals(875626ff-de49-eb11-bb51-000d3a25738b)/journalLines(126001c3-994f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "3",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/journals(875626ff-de49-eb11-bb51-000d3a25738b)/journalLines(136001c3-994f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "4",
            "status": 204,
            "headers": {
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/journals(875626ff-de49-eb11-bb51-000d3a25738b)"
            }
        }
    ]
}

As you can see, no content included for the inserts of the lines. They are gone after the post anyway, so it wouldn’t be useful to have them. And the final call to post the journal also completed successfully.

Let’s now look at a little bit more complicated scenario. I want to create a new sales order, post it and retrieve the posted invoice, all in one go. Let’s look at the body below. The first operation is a POST command to create the sales order including two lines (using a deep insert). The second operation is to post the created sales order (standard ship and invoice) and the third operation is a GET to retrieve the just posted invoice. Can you see where this goes wrong?

{
    "requests": [
        {
            "method": "POST",
            "id": "1",
            "url": "companies({{companyId}})/salesOrders",
            "headers": {
                "Content-Type": "application/json; odata.metadata=minimal; odata.streaming=true",
                "Prefer": "return-no-content"
            },
            "body": {
                "customerId": "{{customerId}}",
                "externalDocumentNumber": "1234",
                "orderDate": "2021-01-05",
                "salesOrderLines": [
                    {
                        "itemId": "{{itemId}}",
                        "quantity": 1
                    },
                    {
                        "itemId": "{{itemId2}}",
                        "quantity": 6
                    }
                ]
            }
        },
        {
            "method": "POST",
            "id": "2",
            "url": "{{url}}/companies({{companyId}})/salesOrders({{salesOrderId}})/Microsoft.NAV.Post",
            "dependsOn": ["1"],
            "headers": {
                "Content-Type": "application/json; odata.metadata=minimal; odata.streaming=true"
            },
            "body": {}
        },
        {
            "method": "GET",
            "id": "3",
            "url": "{{url}}/companies({{companyId}})/salesInvoices({{salesOrderId}})",
            "dependsOn": ["2"]
        }
    ]
}

The problem is that the second and third operations need the id of the sales order that was created with the first operation. However, the id is unknown at the moment when we create the batch call. The question is now, would it be possible to get the id of the created record and use it the next operation? The answer is that the OData specification officially supports this in a change set (aka atomicity group). But as I explained in the previous post, Business Central does not support change sets. Microsoft came up with the Isolation header to support transactions, while the change set feature is really designed for transactions and also supports referencing new entities in the same change set.

Bummer…

OData V4.01 even supports referencing new entities without change sets. However, it appears that Business Central is not using OData v4.01, the response headers include an OData-Version: 4.0 header.

For OData V4.01, the batch request would look like this (note the URLs of operation 2 and 3):

{
    "requests": [
        {
            "method": "POST",
            "id": "1",
            "url": "companies({{companyId}})/salesOrders",
            "headers": {
                "Content-Type": "application/json; odata.metadata=minimal; odata.streaming=true",
                "Prefer": "return-no-content"
            },
            "body": {
                "customerId": "{{customerId}}",
                "externalDocumentNumber": "1234",
                "orderDate": "2021-01-05",
                "salesOrderLines": [
                    {
                        "itemId": "{{itemId}}",
                        "quantity": 1
                    },
                    {
                        "itemId": "{{itemId2}}",
                        "quantity": 6
                    }
                ]
            }
        },
        {
            "method": "POST",
            "id": "2",
            "url": "$1/Microsoft.NAV.Post",
            "dependsOn": ["1"],
            "headers": {
                "Content-Type": "application/json; odata.metadata=minimal; odata.streaming=true"
            },
            "body": {}
        },
        {
            "method": "GET",
            "id": "3",
            "url": "$2",
            "dependsOn": ["2"]
        }
    ]
}

But the response comes back with an error:

{
    "responses": [
        {
            "id": "1",
            "status": 204,
            "headers": {
                "preference-applied": "return-no-content",
                "location": "https://bcsandbox.docker.local:7048/bc/api/v2.0/companies(eaf06bb2-de49-eb11-bb51-000d3a25738b)/salesOrders(6a2c7a9a-a94f-eb11-a856-8ee7d7617d9e)"
            }
        },
        {
            "id": "",
            "status": 500,
            "headers": {
                "content-type": "application/json; charset=utf-8"
            },
            "body": {
                "error": {
                    "code": "Unknown",
                    "message": "This operation is not supported for a relative URI.  CorrelationId:  07ececa3-e062-46b1-aedd-ad4f5919f0cd."
                }
            }
        }
    ]
}

I’ve tried many different scenarios, but unfortunately I was not able to find a way to do a batch call that creates a new entity and then use the created entity in a subsequent operation. To be honest, this really reduces the possible scenarios of batch calls with Business Central. I can only hope Microsoft will support this in a next version of Business Central.

Batch calls with decimals run into an error or timeout

Recently, there was an issue reported on Twitter about batch calls that ran into a timeout and finally return a 500 error. It appears that this happens on the SaaS platform, on-prem environments like a docker container return an error immediately. Consider this batch request:

{
	"requests": [
		{
			"method": "POST",
			"id": "1",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3250.25,
			    "description": "Salary to Bob"
			}
		},
		{
			"method": "POST",
			"id": "2",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": -3500.75,
			    "description": "Salary to John"
	        }
		},
        {
			"method": "POST",
			"id": "3",
			"url": "companies({{companyId}})/journals({{journalId}})/journalLines",
			"headers": {
				"Content-Type": "application/json"
			},
			"body": {
			    "accountId": "{{accountId2}}",
			    "postingDate": "2020-10-20",
			    "documentNumber": "SALARY2020-12",
			    "amount": 6751,
			    "description": "Salaries December 2020"
	        }
		}
    ]
}

The amounts do have decimals, which is perfectly fine with single API calls. But when you do this in a batch call, it results in this error message:

{
    "responses": [
        {
            "id": "1",
            "status": 400,
            "headers": {
                "content-type": "application/json; odata.metadata=minimal",
                "odata-version": "4.0"
            },
            "body": {
                "error": {
                    "code": "BadRequest",
                    "message": "Cannot convert a value to target type 'Edm.Decimal' because of conflict between input format string/number and parameter 'IEEE754Compatible' false/true.  CorrelationId:  8afaf53f-e0df-4101-8000-768aaefdb603."
                }
            }
        }
    ]
}

For unknown reasons, the SaaS environment doesn’t even respond with this error. It just runs for minutes and then finally returns an error 500.

The solution for this error is to add IEEE754Compatible=true to the Content-Type header. More information about this header can be found here. The header can be specified on the batch request level, it does not have to be on the operations in the batch request. The headers will then look like:

POST {{baseurl}}/api/v2.0/$batch
Content-Type: application/json;IEEE754Compatible=true
Accept: application/json

With this Content-Type header the batch request will work as normal.

This was the last post about in the series about batch calls. But certainly not the last one around APIs!

4 thoughts on “Batch calls with Business Central APIs (3) – Tips and Tricks

  1. Hello,
    I have a question about API page.
    I develop business central used API pages with third party app and this third party app use (POST) batch request to my BC and I have another process after the last record of request inserted to BC. How could I check the record that inserting is the last one of request if I don’t want to third party send no. of total record and I don’t want count record on BC.

    Thanks in advance.

    Ps. Sorry for my English skill.

    • If I understand you correctly, then you want to know if an inserted record is the last one of a batch and then start another process.

      This cannot be done by monitoring the inserted records, there is no way to find out if it was the last one. Or you must have an extra boolean field that indicates the last record.

      You could think of adding an extra call to the batch, to an unbound function and make sure that this call will happen as last one. Then you can start the process with that last call from the batch instead of monitoring inserted records.

      • Thank you very much for your reply. You understood me corectly.
        I will do follow your suggestion (adding an extra call).

        And…
        Could I ask another one question please.
        How could I edit response when the request from third party are completly?

        e.g. Another app send POST request to BC 100 records and when the last record inserted to BC I want to message to them that I received 100 records complete.

        Thank you again sorry for bothering you.

        • I wonder why you want to message about the 100 records? If the batch call is completed, then the calling application receives a response immediately with the results. Why would you add another confirmation to that? I don’t think it’s possible, but I also don’t see the need.

Leave a Reply

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