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!