Calling the Business Central API Without the Guesswork
How Business Central API v2.0, custom API and OData web service URLs are built, how to quote $filter values by type, encode company names, page through results and call it all from curl, Python and Power Query.
Most of the time I spend on a Business Central integration isn’t spent on the integration. It’s spent on the URL. A wrong company segment, a date in quotes, a field called Posting Date instead of Posting_Date, and Business Central answers with a 400 Bad Request that doesn’t always say which part it didn’t like.
This post collects the rules I keep looking up: the three URL shapes, how to write $filter values for each field type, how to encode company names, and how to page through large results. At the end there are ready-to-use calls for curl, Python and Power Query.
Try it: OData & API URL Builder — pick the endpoint, fields, filters and sort order, and get the URL quoted and encoded the way Business Central expects, with curl, Python and Power Query to call it. It only writes the URL; it never connects to Business Central.
The three URL shapes
Business Central exposes data through three kinds of endpoint, and each has its own address.
| Endpoint | What it serves | Company is identified by |
|---|---|---|
| API v2.0 | Microsoft’s standard APIs (customers, salesInvoices, generalLedgerEntries, …) | The company’s id, a GUID |
| Custom API | API pages and API queries from an extension | The company’s id, a GUID |
| OData web service | Pages and queries published on the Web Services page | The company’s name, in quotes |
For Business Central online, all three start with the same host, tenant and environment:
1
https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}
Then the route differs:
1
2
3
API v2.0 …/api/v2.0/companies({company-id})/salesInvoices
Custom API …/api/{publisher}/{group}/{version}/companies({company-id})/{entitySetName}
OData service …/ODataV4/Company('{company-name}')/{serviceName}
{tenant}is your Microsoft Entra tenant, as a domain (contoso.onmicrosoft.com) or its ID.{environment}is the environment name exactly as the admin center shows it, such asProductionorSandbox.- For a custom API,
{publisher},{group}and{version}are theAPIPublisher,APIGroupandAPIVersionproperties of the API page or query, and{entitySetName}is itsEntitySetName. - For an OData web service,
{serviceName}is the Service Name column on the Web Services page, not the page’s caption.
On-premises, the host part is your service URL instead: server, OData/API port (7048 by default) and server instance, such as https://bc.contoso.local:7048/BC. The routes after it are the same. A multitenant server also needs ?tenant=… in the query string.
Step one: get the company id
The API routes take the company’s id, not its name. Passing companies(CRONUS International Ltd.) is one of the most common mistakes, and it fails. Ask the companies endpoint first. It is the one entity that sits above the company segment:
1
https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies?$select=id,name,displayName
Each record in value has an id. Copy the one you need into companies(…), without quotes or braces.
OData web services work the other way round. They want the name, in single quotes, inside Company(…). More on encoding it below.
Writing a $filter Business Central accepts
OData v4 filters are field operator value, joined with and or or. The operators are eq, ne, gt, ge, lt and le. For text there are also the functions startswith, endswith and contains.
The part that trips people up is the value. Whether it goes in quotes depends on the field’s type:
| Field type | Example | Quotes? |
|---|---|---|
| Text, Code | customerName eq 'Contoso' | Yes, single quotes |
| Text with an apostrophe | customerName eq 'O''Brien' | Yes, and the apostrophe is written twice |
| Enum / option | status eq 'Open' | Yes |
| Integer, Decimal | balanceDue gt 0 | No |
| Date | postingDate ge 2026-07-17 | No |
| Date and time | lastModifiedDateTime gt 2026-10-01T00:00:00Z | No, but it needs a time zone |
| Boolean | blocked eq false | No |
| GUID | id eq 5d115c9c-44e3-ea11-bb43-000d3a2feca1 | No |
| Text function | startswith(displayName,'C') | The value, yes |
Three details from that table cause most of my failed calls:
- Dates aren’t strings.
postingDate ge '2026-07-17'compares a date to text and fails. Leave the quotes off. - Date-time values need a zone. Business Central stores date-time fields in UTC, so write
Z(or an offset like+05:45) at the end.2026-10-01T00:00:00on its own is ambiguous. - Apostrophes are doubled, not escaped.
'O\'Brien'doesn’t work in OData.'O''Brien'does.
contains is convenient but slow on a large table, because it can’t use an index the way an exact match or startswith can. On ledger entries, prefer the other two when you can.
Note that this is OData filter syntax. AL’s SetFilter syntax (10000..20000|C*) is a different language, and it doesn’t work in a URL.
Try it: Filter Expression Tester — for the AL side: test SetFilter expressions like 10000..20000|C* against your own values and see how each one is read.
Field names: API pages vs. OData web services
The standard and custom APIs use camelCase field names that the API page defines: number, displayName, postingDate, totalAmountIncludingTax.
OData web services built from ordinary pages use names derived from the field captions. Anything that isn’t a letter or digit becomes _:
| Caption on the page | OData name |
|---|---|
| No. | No |
| Posting Date | Posting_Date |
| Balance (LCY) | Balance_LCY |
| Sell-to Customer No. | Sell_to_Customer_No |
E_Mail |
When Business Central says a property doesn’t exist, the quickest check is the service’s metadata: add /$metadata after …/ODataV4/ and search for the entity. Every property is listed with its exact name and type.
Encoding company names
Inside Company('…'), the name is a string, so the same quoting rule applies: an apostrophe is doubled. Then the whole thing is URL-encoded, because spaces, & and other characters can’t appear raw in a URL. A company called O’Brien & Sons, Ltd. becomes:
1
…/ODataV4/Company('O''Brien%20%26%20Sons,%20Ltd.')/Customer_Card
The & matters most. Left unencoded, it ends the path and starts a query parameter, and Business Central looks for a company called O'Brien .
Choosing fields, related records and order
The other query options are simpler, but worth knowing:
| Option | Example | What it does |
|---|---|---|
$select | $select=number,customerName,totalAmountIncludingTax | Returns only these fields. Responses get smaller and faster. |
$expand | $expand=salesInvoiceLines | Returns related records inside each record. |
Nested $select | $expand=salesInvoiceLines($select=description,quantity,amountIncludingTax) | Picks the related records’ fields too. |
$orderby | $orderby=postingDate desc | Sort order. Without it, records come back in primary key order. |
$top | $top=50 | At most this many records. |
$skip | $skip=50 | Skips this many first. Always pair it with $orderby, or pages can shift between calls. |
$count | $count=true | Adds @odata.count, the number of matching records, to the response. |
Paging: follow @odata.nextLink
Business Central doesn’t return a large result in one response. It returns a page of records and an @odata.nextLink property with the URL of the next page. Keep requesting @odata.nextLink until a response doesn’t have one. Code that reads only the first response silently drops everything after the first page. That is easy to miss in testing, when the sandbox has only a handful of records.
Tenant and sign-in
Online, Business Central accepts only OAuth access tokens from Microsoft Entra ID. Two things follow from that:
- An app that signs in as itself (client credentials, service-to-service) must put the tenant in the URL:
/v2.0/{tenant}/{environment}/…. Without it, Business Central can’t tell which tenant the call is for. - A call made as a signed-in user can leave the tenant out, because the token already says which tenant the user belongs to.
On-premises, the usual option is the user name and web service access key, sent as basic authentication. Windows authentication also works where the server is set up for it.
A worked example
Say I want the 50 most recent open sales invoices posted since 17 July 2026, with only the fields a dashboard needs. With the builder (or by hand), that is:
- Endpoint: API v2.0, entity
salesInvoices - Fields:
number,postingDate,customerName,status,totalAmountIncludingTax - Filters:
statusequalsOpen, andpostingDateat least2026-07-17 - Order:
postingDate, descending, top 50
Readable, the URL is:
1
https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies({company-id})/salesInvoices?$select=number,postingDate,customerName,status,totalAmountIncludingTax&$filter=status eq 'Open' and postingDate ge 2026-07-17&$orderby=postingDate desc&$top=50
And encoded, which is what you actually send:
1
https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies({company-id})/salesInvoices?$select=number,postingDate,customerName,status,totalAmountIncludingTax&$filter=status%20eq%20'Open'%20and%20postingDate%20ge%202026-07-17&$orderby=postingDate%20desc&$top=50
status is an enum, so 'Open' is quoted. postingDate is a date, so 2026-07-17 isn’t.
Calling it
curl
1
2
3
curl -H "Authorization: Bearer $BC_TOKEN" \
-H "Accept: application/json" \
'https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies({company-id})/salesInvoices?$select=number,postingDate,customerName,status,totalAmountIncludingTax&$filter=status%20eq%20%27Open%27%20and%20postingDate%20ge%202026-07-17&$orderby=postingDate%20desc&$top=50'
The URL is in single quotes on purpose. In double quotes, the shell would read $select, $filter and $orderby as variables and replace them with nothing. Business Central would then return every invoice, unfiltered, and nothing would tell you why. An apostrophe can’t appear inside single quotes, so 'Open' is sent as %27Open%27, which means the same thing.
Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
url = "https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies({company-id})/salesInvoices?$select=number,postingDate,customerName,status,totalAmountIncludingTax&$filter=status%20eq%20'Open'%20and%20postingDate%20ge%202026-07-17&$orderby=postingDate%20desc"
token = "..." # an OAuth access token from Microsoft Entra ID, for example from msal
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {token}", "Accept": "application/json"})
rows = []
while url:
response = session.get(url, timeout=60)
response.raise_for_status()
data = response.json()
rows += data["value"]
url = data.get("@odata.nextLink") # the next page, if there is one
print(len(rows), "records")
This one drops $top and follows @odata.nextLink, so it collects every matching invoice however many pages there are. Getting the token itself is covered in Integrating Python with Microsoft Dynamics 365 Business Central.
Power Query (Excel, Power BI)
1
2
3
4
let
Source = OData.Feed("https://api.businesscentral.dynamics.com/v2.0/{tenant}/Production/api/v2.0/companies({company-id})/salesInvoices?$select=number,postingDate,customerName,status,totalAmountIncludingTax&$filter=status%20eq%20'Open'%20and%20postingDate%20ge%202026-07-17&$orderby=postingDate%20desc", null, [Implementation = "2.0"])
in
Source
Power Query handles the sign-in and the paging for you. Putting $select and $filter in the URL means Business Central does the filtering, rather than Power Query downloading everything first.
Practical checklist
When a Business Central call fails or returns the wrong records:
- Check the route. API v2.0, a custom API and an OData web service each have their own address. Don’t mix them.
- Check the company. APIs take
companies({id})with a GUID. OData web services takeCompany('{name}'). - Check every filter value’s quotes. Text and enums are quoted. Numbers, dates, booleans and GUIDs aren’t. An apostrophe inside text is doubled.
- Check date-time values for a time zone. End them with
Zor an offset. - Check field names against
$metadata. They’re camelCase on APIs and caption-based on OData web services. - Check the encoding. Spaces are
%20, and an&in a company name is%26. - Check paging. Follow
@odata.nextLink, and pair$skipwith$orderby. - Check the tenant. Client-credentials apps need it in the URL.
- Check your shell. In curl, put the URL in single quotes.
Summary
Calling Business Central is mostly about getting the URL right. Pick the right one of the three routes, identify the company the way that route expects, quote each filter value by its type, and encode what a URL can’t carry. Then follow @odata.nextLink so you get every page, not just the first. For more on OData query syntax in general, see Mastering OData Queries in D365 Web API. The OData & API URL Builder applies all of these rules for you, so you can copy a URL that works first time.