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!
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.
Hi ajk,
many thanks for these very useful posts. Just learned a lot of new stuff around APIs. Also the deep insert blogs were very helpful, also thank you for this!
While looking at the batch calls and start playing around with it, I was just wondering if it would be posible to assign a common transaction no. within BC to all records received within a given batch request. I can use BC no. series mgt. in order to define a new transaction no. but still haven’t found any sufficient trigger where to place it to make it resistant through the overall batch request. Obviously each single request causes a new instantiation of the BC API page, thus triggering a new transaction no for each.
Do you see any option to resolve this or would you say it just won’t work due to how the API pages are handled by the batch request?
Thanks in advance
AndrewT
Interesting scenario. Maybe the Isolation: snapshot header would help? Then all requests will be isolated, they only see the data that has been modified by the requests in the batch. The first request could be a start-batch request, which sets a transaction number for the requests to follow. The batch should end with a end-batch request which removes the transaction number.
The only thing I’m not 100% sure about is what happens outside the batch. If a batch is running in isolated mode, it does not see any data from other sessions that run at the same time. But what about the other sessions? Don’t they see the data from the isolated batch before it finishes? I haven’t tested that, and I expect it will be fine. But you should test it nonetheless, just to be sure.
Yes, it is. The initial idea is to have an identifier on the transaction log in BC to help grouping the data content for individual transactions which may consist of multiple records when it comes to batch requests, right 😉
But we now better understand why it is not that easy to implement as intended to be due to how the API objects in BC are handled through batch requests.
I basically agree with the Isolation: snapshot option as possible solution but that would of cause also require some overhead on the consumer site creating the API requests (which is not us in this particular case).
We’ll give it a try but at this point I think I would like to keep the interface as simple as possible, thus better get rid of the idea for having this transaction no. thing for now and hoping for the changeset / atomicity group support for BC to come – I think that’s what’s really needed here and finally would do the trick.
Many thanks for your help on getting a clear picture to it.
AndrewT
It seems IEEE754Compatible=true is no more required with Business Central 2022 wave 1. Even worse it raises an “Invalid Request Body” error.
Have you noticed that?
Hi,
I’ve just checked this out – the service still returns an error 500 when issuing a batch request without the Content-Type set to application/json;IEEE754Compatible=true. Just as described above by ajk in his original comment.
Staying with this parameter set, the request just passes through the BC API as usual.
Tested on BC SaaS version DE Business Central 20.0 (Platform 20.0.37114.40656 + Application 20.0.37253.38055)
Hi, many thanks for these very useful posts. Just learned a lot around APIs which is new to me. I have manged to create and post JounalLines now into Business Central. I am however unable to post into ShortcutDimension1 or 2 as I keep getting “Invalid Reuest Body”. How do I find out if the standard API can accomodate dimensions and if so what the correct field name I should be using?