Skip to content

Read Data records from a .NET server

Ebitex.Data.Delivery is a server-side client for the Data Delivery API. It lists the models a key can read, then lists, filters, sorts, counts and pages records, follows references, and reads records by key, returning each as a typed .NET value. It is the .NET counterpart of @ebitex/data-sdk, and the two are held to each other by shared test cases.

The package targets .NET 10 and depends only on Microsoft.Extensions.Http and Microsoft.Extensions.Options. It works the same in ASP.NET Core, a worker service or a console app.

Every C# method this page names is in one file, shown in full at the end of the page under The complete example.

You need a server delivery key for a partition. In Data, open Settings and Delivery keys, create a key and leave it unrestricted. A server key reads everything in its partition, so keep it on your server and out of source control. In development, .NET user secrets work well (a Data:DeliveryKey entry).

A browser-safe key cannot be used here. It only answers requests that carry one of its allowed origins, and a server sends no Origin header, so every request is refused with 403 origin_denied.

Terminal window
dotnet add package Ebitex.Data.Delivery

AddEbitexDataDelivery registers DataDeliveryClient as a typed client through IHttpClientFactory, which pools connections and refreshes DNS correctly in a long-running server. Inject DataDeliveryClient wherever you need it. The method returns an IHttpClientBuilder, so you can add handlers or resilience policies to it as usual. See Configure in the example.

Without dependency injection, construct the client with an HttpClient you own and reuse it for the life of the process. See Create in the example.

Option Default Meaning
ApiKey Required Your server delivery key. Checked when the client is created.
BaseUrl https://api.ebitex.io The API’s origin. The client adds the /data/delivery/v1 path itself, so leave it out.

The client holds no cache and retries nothing. Add either as a handler on the HttpClient.

Read the models first. FilterableFields in the example lists the fields a model’s owner made filterable.

ListRecordsAsync returns one page of a RecordsQuery:

Property Meaning
Model A model’s records and those of every model that inherits from it. Needed for filters, facets and a field sort.
Filters Field id to FieldFilter. A bare value converts to equals; or set Eq, In, Prefix, Gt, Gte, Lt, Lte, Exists. A DateTimeOffset is sent as an RFC 3339 instant. Combined with AND, up to 10 operators in all.
Search Matches the start of a record’s name, or of any word in it, without regard to case.
Sort name (the default), updated, or a sortable field. A leading - sorts descending.
Facets Up to 5 facetable fields, and the built-in model. Counts cover every match, not only the page.
Limit 1 to 100.
Generations, Expand Follow references.

A request the API would certainly refuse is refused by the client first, with the same error code, as a DataDeliveryException whose IsLocal is true. A field that is private, undeclared or absent answers the same way: field_not_queryable.

A page carries a NextCursor, null on the last page, and a cursor belongs to its query. ListAllRecordsAsync and ListPagesAsync send the identical query with every cursor and raise the error if one is ever rejected. They never start over, because that would silently repeat or skip records.

GetRecordsByKeysAsync reads up to 50 records in one round trip, in the order asked. A key that does not exist, or that your key may not read, is in Missing.

GetRecordAsync returns one record, or null when there is none. Those two answers are the same on purpose, so a key cannot learn that a record it may not read exists. Set ThrowIfMissing to get an exception instead. GetRecordResponseAsync keeps the ETag and sends If-None-Match, so an unchanged record is RecordStatus.NotModified.

Generations (0 to 5) puts each referenced record beside its pointer. DataValues.Expanded returns the record a reference was expanded to, or null for a pointer that was not followed. A request expands at most 200 records, and Meta.Truncated says when that cut it short.

A non-success response is a DataDeliveryException: StatusCode, ErrorCode (the API’s error code), Message, RequestId to quote to support, and RetryAfter on a 429. A connection failure is the HttpRequestException the HttpClient throws.

DocsExamples.cs
// Examples for the Ebitex.Data.Delivery package. Each method is one task from the .NET guide.
// The file is compiled against the published package, so every call in it is public API.
using Ebitex.Data.Delivery;
using Microsoft.Extensions.DependencyInjection;
namespace Drakeshead.Web;
public static class DocsExamples
{
// Register the client once, at startup. Read the key from configuration or a secret store, never from source
// control, and use a SERVER key: a browser-safe key is refused from a server. Inject DataDeliveryClient where needed.
public static IServiceCollection Configure(IServiceCollection services, string deliveryKey)
{
services.AddEbitexDataDelivery(options => options.ApiKey = deliveryKey);
return services;
}
// Without dependency injection, pass an HttpClient you own and reuse the client for the life of the process.
public static DataDeliveryClient Create(HttpClient http, string deliveryKey) =>
new(http, new DataDeliveryOptions { ApiKey = deliveryKey });
// Read the models first: it is the list of what this key may ask for.
public static async Task<IReadOnlyList<string>> FilterableFields(DataDeliveryClient data, string model)
{
var models = await data.GetModelsAsync();
return models.Models.Single(m => m.Key == model).Fields.Where(f => f.Queryable?.Filter == true).Select(f => f.Id).ToList();
}
// One page: records of "dog" and of every model that inherits from it, filtered, sorted and counted.
public static async Task<RecordsPage> BlackAndRedDogs(DataDeliveryClient data) =>
await data.ListRecordsAsync(new RecordsQuery
{
Model = "dog",
Filters = new Dictionary<string, FieldFilter>
{
["colour"] = new FieldFilter { In = ["black", "red"] },
["born"] = new FieldFilter { Gte = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero) },
},
Sort = "-born",
Facets = ["colour"],
Limit = 25,
});
// Every record a query matches. The query is sent unchanged with each page's cursor, because a cursor belongs to its query.
public static async Task<List<string>> EveryDogsName(DataDeliveryClient data)
{
var names = new List<string>();
await foreach (var dog in data.ListAllRecordsAsync(new RecordsQuery { Model = "dog", Sort = "name", Limit = 100 }))
{
names.Add(dog.Name ?? dog.Key);
}
return names;
}
// Up to 50 records by key in one round trip. A key that does not exist, or that this key may not read, is in Missing.
public static async Task<BatchResult> ByKey(DataDeliveryClient data) =>
await data.GetRecordsByKeysAsync(["rex-of-drakeshead", "no-such-dog"]);
// Follow references two hops. The target sits beside its pointer; Expanded is null for a pointer that was not followed.
public static async Task<string?> SiresName(DataDeliveryClient data)
{
var dog = await data.GetRecordAsync("rex-of-drakeshead", new RecordOptions { Generations = 2 });
return DataValues.Expanded(dog?.Value("sire"))?.Name;
}
// Responses are private, no-cache with an ETag: ask again, and an unchanged record costs a 304 instead of a body.
public static async Task<string?> Revalidate(DataDeliveryClient data, string? etag)
{
var result = await data.GetRecordResponseAsync("rex-of-drakeshead", new RecordOptions { IfNoneMatch = etag });
return result.Status == RecordStatus.NotModified ? etag : result.ETag;
}
// A refusal carries the server's code and the request id to quote to support.
public static async Task<string> Handle(DataDeliveryClient data)
{
try
{
await data.ListRecordsAsync(new RecordsQuery { Model = "dog", Filters = new Dictionary<string, FieldFilter> { ["weight"] = new FieldFilter { Gt = 10 } } });
return "ok";
}
catch (DataDeliveryException error) when (error.StatusCode == 429)
{
return $"try again in {error.RetryAfter}";
}
catch (DataDeliveryException error)
{
return $"{error.StatusCode} {error.ErrorCode} (quote {error.RequestId})";
}
}
}