Post

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.

Calling the Business Central API Without the Guesswork

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.

EndpointWhat it servesCompany is identified by
API v2.0Microsoft’s standard APIs (customers, salesInvoices, generalLedgerEntries, …)The company’s id, a GUID
Custom APIAPI pages and API queries from an extensionThe company’s id, a GUID
OData web servicePages and queries published on the Web Services pageThe 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 as Production or Sandbox.
  • For a custom API, {publisher}, {group} and {version} are the APIPublisher, APIGroup and APIVersion properties of the API page or query, and {entitySetName} is its EntitySetName.
  • 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 typeExampleQuotes?
Text, CodecustomerName eq 'Contoso'Yes, single quotes
Text with an apostrophecustomerName eq 'O''Brien'Yes, and the apostrophe is written twice
Enum / optionstatus eq 'Open'Yes
Integer, DecimalbalanceDue gt 0No
DatepostingDate ge 2026-07-17No
Date and timelastModifiedDateTime gt 2026-10-01T00:00:00ZNo, but it needs a time zone
Booleanblocked eq falseNo
GUIDid eq 5d115c9c-44e3-ea11-bb43-000d3a2feca1No
Text functionstartswith(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:00 on 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 pageOData name
No.No
Posting DatePosting_Date
Balance (LCY)Balance_LCY
Sell-to Customer No.Sell_to_Customer_No
E-MailE_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 .

The other query options are simpler, but worth knowing:

OptionExampleWhat it does
$select$select=number,customerName,totalAmountIncludingTaxReturns only these fields. Responses get smaller and faster.
$expand$expand=salesInvoiceLinesReturns related records inside each record.
Nested $select$expand=salesInvoiceLines($select=description,quantity,amountIncludingTax)Picks the related records’ fields too.
$orderby$orderby=postingDate descSort order. Without it, records come back in primary key order.
$top$top=50At most this many records.
$skip$skip=50Skips this many first. Always pair it with $orderby, or pages can shift between calls.
$count$count=trueAdds @odata.count, the number of matching records, to the response.

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: status equals Open, and postingDate at least 2026-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:

  1. Check the route. API v2.0, a custom API and an OData web service each have their own address. Don’t mix them.
  2. Check the company. APIs take companies({id}) with a GUID. OData web services take Company('{name}').
  3. Check every filter value’s quotes. Text and enums are quoted. Numbers, dates, booleans and GUIDs aren’t. An apostrophe inside text is doubled.
  4. Check date-time values for a time zone. End them with Z or an offset.
  5. Check field names against $metadata. They’re camelCase on APIs and caption-based on OData web services.
  6. Check the encoding. Spaces are %20, and an & in a company name is %26.
  7. Check paging. Follow @odata.nextLink, and pair $skip with $orderby.
  8. Check the tenant. Client-credentials apps need it in the URL.
  9. 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.

This post is licensed under CC BY 4.0 by the author.