As part of an SAP integration project for a client, I had to consume OData APIs exposed by SAP Gateway from a C#/.NET 8 application. The goal: retrieve business data (invoices, attachments, accounting entries) to synchronize it with other systems through Azure.
It was in this context that I discovered the strengths — and the many pitfalls — of OData within the SAP ecosystem. This article is based on concrete experience, not a generic tutorial.
OData (Open Data Protocol) is an open standard created by Microsoft in 2007, then standardized by OASIS in 2014. It is not a protocol in its own right: it is an enhanced REST convention built on top of HTTP.
The central idea is simple and powerful: expose data like a database that can be queried through HTTP URLs. Instead of multiplying endpoints (/products/expensive, /products/by-category…), OData lets the client decide what it wants, directly in the URL:
GET /Products?$filter=Price gt 100&$orderby=Name&$top=10
There are two major versions:
Without OData, for every filtering, sorting, or pagination requirement, the backend developer must create a specific endpoint, handle the different cases in server-side code, and then version it whenever a new client-side requirement arises.
The result: an explosion in the number of endpoints, documentation that is difficult to maintain, and constant back-and-forth between teams.
With OData, the client is in control:
GET /invoices/paidGET /invoices?$filter=Status eq 'PAID'
GET /invoices/page/3GET /invoices?top=20& skip=40
GET /invoices/sorted-by-dateGET /invoices?$orderby=Date desc
GET /invoices/{id}/items followed by a joinGET /invoices?$expand=Items
For an ERP like SAP, which exposes thousands of business entities, this is an ideal approach: a single protocol, a single convention, for all data.
The real strength of OData lies not only in its consistency, but also in what it eliminates on the backend side: there is no longer a need to multiply endpoints for every combination of filtering, sorting, or pagination requested by clients. The protocol exposes a set of standardized query parameters, prefixed with $, which shift this logic to the client side — the client decides what to retrieve and how.
On the project, these parameters made it possible to drastically reduce the number of calls to SAP by retrieving only the necessary fields and filtering directly on the server side rather than through C# post-processing. Here are the most commonly used ones in this context.
Using $select avoids retrieving 50 fields when only 3 are needed. This is essential for network performance.
GET /Invoices?$select=Id,Amount,Date
$filter supports four categories of operators, which can be freely combined in the same query: comparison operators to evaluate a value, logical operators to combine multiple conditions, and functions dedicated to strings and dates when simple comparisons are not enough.
GET /Invoices?$filter=Amount gt 1000 and Status eq 'PAID'
$orderby sorts results by one or more fields, each of which can be specified in ascending (asc, by default) or descending (desc) order.
GET /Invoices?$orderby=Date desc,Amount asc
$top limits the number of results returned, and $skip specifies how many items to skip before starting to read them. Combined, they allow you to manually paginate a collection.
Warning: SAP may cap or ignore $top without indicating it. See the lessons learned section.
GET /Invoices?$top=20&$skip=40 → page 3 (index 0)
This command is the equivalent of a SQL JOIN: it returns the order and its lines in a single call, avoiding the N+1 problem.
GET /Orders?$expand=OrderItems
This returns the total number of items in addition to the results. It is particularly useful for UI-side pagination.
GET /Invoices?$count=true
Here, we request the complete XML schema: all entities, their fields, types, and relationships. This is the actual API documentation, often more reliable than the official SAP documentation. It should be the first thing to check when starting to integrate a service.
GET /sap/opu/odata/sap/API_CV_ATTACHMENT_SRV/$metadata
SAP Gateway is the SAP component that exposes ERP data through OData. All public SAP APIs (available on the SAP API Business Hub) use OData v2 or v4 (SAP API Business Hub: https://api.sap.com).
Some examples of common SAP OData APIs:
An important point: SAP Fiori itself consumes these OData APIs to display its interfaces. What we call from C# is exactly what the Fiori interface uses, which can help verify expected behavior by inspecting network requests in the browser.
This is the most flexible approach. It is essential when third-party libraries do not handle SAP-specific requirements (specific headers, non-standard data formats).
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")));
client.DefaultRequestHeaders.Add("sap-client", "100");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var url = "https://sap.example.com/sap/opu/odata/sap/API_SUPPLIER_INVOICE_SRV/A_SuplrInvcItemPurOrdRef"
+ "?$filter=FiscalYear eq '2024'"
+ "&$select=SupplierInvoice,DocumentItemText,AmountInTransactionCurrency"
+ "&$top=100";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
Tip: always log the complete URL in debug mode. The vast majority of OData errors come from an incorrectly constructed URL (missing encoding, forgotten quotes, incorrect operator).
A .NET library that abstracts URL construction and provides a fluent API.
var odataClient = new ODataClient("https://sap.example.com/odata/");
var invoices = await odataClient.For("A_SuplrInvcItemPurOrdRef")
.Filter(x => x["FiscalYear"] == "2024")
.Select("SupplierInvoice", "AmountInTransactionCurrency")
.Top(100)
.FindEntriesAsync();
Generates strongly typed C# classes from $metadata. Convenient in theory, but generating code from complex SAP $metadata is often tedious and produces classes that are difficult to maintain. Best reserved for projects with few entities and stable $metadata.
The authentication method to use depends on the context: development environment, on-premises system, or SAP S/4HANA Cloud. Here are the main options encountered on this type of project.
Before any modification operation, SAP requires a CSRF token. The process consists of two steps:
Step 1 — Retrieve the token
var fetchRequest = new HttpRequestMessage(HttpMethod.Get, baseUrl + "/$metadata");
fetchRequest.Headers.Add("X-CSRF-Token", "Fetch");
var fetchResponse = await client.SendAsync(fetchRequest);
var csrfToken = fetchResponse.Headers.GetValues("X-CSRF-Token").FirstOrDefault();
Step 2 — Use the token in the mutation
var postRequest = new HttpRequestMessage(HttpMethod.Post, url);
postRequest.Headers.Add("X-CSRF-Token", csrfToken);
postRequest.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var postResponse = await client.SendAsync(postRequest);
Without this token, SAP returns a 403 Forbidden error with the message CSRF token validation failed.
This is probably the most frustrating part of SAP OData integration.
These pitfalls mainly concern OData v2: the /Date(timestamp)/ format is a historical JSON serialization convention (inherited from Microsoft’s verbose OData format), which SAP has retained. OData v4 generally uses the standard ISO 8601 format, but in practice, many SAP services exposed in v2 — still very common on on-premises S/4HANA — continue to return this proprietary format. This is why it is important to check the service version before writing deserialization code.
SAP OData v2 serializes dates in a proprietary format:
{
"PostingDate": "/Date(1704067200000)/"
}
This timestamp is expressed in milliseconds since the Unix epoch (January 1, 1970 UTC). System.Text.Json cannot deserialize this format natively — a custom JsonConverter is required:
public class SapDateConverter : JsonConverter<DateTime?>
{
private static readonly Regex SapDateRegex = new(@"/Date\((-?\d+)\)/");
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (string.IsNullOrEmpty(value))
return null;
var match = SapDateRegex.Match(value);
if (!match.Success)
return null;
var ms = long.Parse(match.Groups[1].Value);
if (ms == -62135596800000L)
return null;
return DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value == null)
{
writer.WriteNullValue();
return;
}
var ms = new DateTimeOffset(value.Value, TimeSpan.Zero).ToUnixTimeMilliseconds();
writer.WriteStringValue($"/Date({ms})/");
}
}
The filtering syntax uses the datetime keyword as follows:
$filter=PostingDate ge datetime'2024-01-01T00:00:00'
But this is not a direct ISO 8601 date as you might expect. This syntax is specific to OData v2 and is not always clearly documented.
SAP returns /Date(-62135596800000)/ for date fields that have not been populated. This value corresponds to January 1, 0001 in .NET. It must be explicitly detected and treated as null, otherwise you end up with invalid dates in the data.
SAP generally stores dates in UTC+0 but returns them without an explicit timezone indication. If your application is in a different timezone, one-day offsets may appear in filters. This is why comparisons should always be performed in UTC on the C# side.
After several months of integrating different SAP OData services on this project, some behaviors proved to be as frustrating as they were recurring — and none of them are mentioned in the official documentation or on the SAP API Business Hub. You only discover them in production or through repeated manual testing, often after wasting time looking for a C# error that never existed. Here are the main pitfalls encountered on this project.
Some syntactically valid $filter expressions are simply ignored by SAP without returning an error: the response contains results, but not the expected ones. This is particularly misleading. Always validate a filter by manually comparing the returned results with the expected ones.
In OData v2, SAP uses substringof — the reverse of contains in terms of parameter order:
$filter=contains(Name, 'Dupont')$filter=substringof('Dupont', Name)
Using contains with SAP OData v2 often returns an error or no results, depending on the endpoint.
Some SAP services cap the number of results (often at 1000) regardless of the value passed in $top. This maximum is generally not documented. To retrieve all the data, pagination must be implemented using $skip or the @odata.nextLink returned in the response.
The technical name of a field in the OData URL may be completely different from the label displayed in the Fiori interface. The source of truth remains $metadata — not the documentation or the user interface.
Even if a relationship is defined in $metadata, some SAP endpoints do not support $expand on it. The server then returns a 400 or 501 error. In this case, separate calls must be made and the join performed on the C# side.
OData is an elegant protocol that considerably simplifies the exposure and consumption of business data. In the SAP ecosystem, it is essential. However, SAP’s implementation has enough specificities to warrant caution.
Summary of key points to remember:
Investing in a good OData abstraction layer (configured HTTP client, date converter, CSRF token management) quickly pays off when integrating multiple SAP services within the same project.