Use IExternalErpClient for application workflows that should remain portable across ERP providers. Use IDkPlusClient for DK Plus-specific writes, generated documents, tables, tokens, and detailed child resources.
The public facade uses singular roots. The previous plural flat aliases are not part of this release. Every read member of IDkPlusClient returns the sealed DkPlus<Entity> subtype of the portable model, while IExternalErpClient returns the portable type.
Customer, with Customer.Transaction, Customer.Group, Customer.Contact, and Customer.ReceiverProduct, with Product.Group, Product.Category, and Product.UnitEmployee, Dimension, Currency, and Payment.Mode/Term/TypeSales.Invoice, Sales.Order, Sales.Quote, Sales.Recurring, and Sales.PersonPurchase.Vendor, including Purchase.Vendor.Transaction, and Purchase.Invoice. IExternalErpClient.Purchase.Vendor is the read-only portable view of vendors (ErpCapability.Vendors); vendor invoices stay DK-specificGeneralLedger.Account, GeneralLedger.Transaction, GeneralLedger.Journal, and GeneralLedger.TaxGroupProject.Phase, Project.Task, Project.WorkRequest, Project.Transaction, Project.Journal, and Project.WorkEntryCompany, Table, Token, Webhook, Permission, Inventory, Warehouse, Nation, and MyPagesThis keeps the connection and credentials consistent across a workflow while making related operations discoverable.
IErpQuery<T> is a small provider-neutral query contract. It intentionally does not accept LINQ expression trees because expressions tied to one provider tend to create surprising behavior in another adapter.
var changedCustomers = await client.Customer.Query()
.ForGroup("RETAIL")
.ModifiedSince(lastSuccessfulRun)
.WithPageSize(200)
.ToListAsync(cancellationToken);
ToListAsync follows all pages until DK Plus returns a short page, and continues past DK Plus's 50,000-record paging limit (see Paging limit). Take(n) caps the total returned results while keeping the provider page size stable between requests, which prevents page offsets from skipping or repeating records on the final partial result. ToPageAsync returns one page plus an opaque continuation token that preserves the next page, page size, and remaining Take limit.
var page = await client.Product.Query()
.WithPageSize(100)
.ToPageAsync(previousContinuationToken, cancellationToken);
Use AsAsyncEnumerable when each item can be handled immediately. It loads one provider page at a time instead of first accumulating the full result in memory:
await foreach (var customer in client.Customer.Query()
.ModifiedSince(lastSuccessfulRun)
.AsAsyncEnumerable(cancellationToken))
{
await HandleAsync(customer, cancellationToken);
}
DK Plus serves at most 50,000 records per query: a request is accepted while page x size is at most 50,000, so page 51 at size 1000 fails with HTTP 400. Fluent queries (ToListAsync, AsAsyncEnumerable, and ToPageAsync) continue past the limit automatically by re-anchoring a modification-time window, the pattern the DK Plus maintainer recommends:
ObjectDate (the time dkPlus stored the record) when the DK Plus response model of its list endpoint has one, otherwise Modified. Customers, products, vendors, sales invoices, customer transactions, v2 orders, quotes, recurring sales, and project work journals page on ObjectDate; projects, dimensions, project phases, and project tasks page on Modified.ModifiedSince), keeps ModifiedBefore and every other filter, and restarts at page 1.Take and WithPageSize keep their meaning. The CLI's --all and the PowerShell module's -All use the same fluent query, so they read past 50,000 records the same way, and the CLI's --take and the module's -Take stop at 1000 on the fluent list commands (the library's Take() itself stays unbounded), which always fits in one window; use --all / -All to read more. Continuation tokens from ToPageAsync carry the window state and a hash of the query's filters (including the typed customer and product filters), so a resumed query continues correctly across a re-anchor. Tokens are opaque, may contain timestamps and hashed record identities, and should be treated like query data and not logged. A token created by an earlier client version, by a query for another record type, or by a query with other filters is rejected with ArgumentException, so restart the query without a token.
The method relies on DK Plus returning records in ascending order of the paging timestamp, which was observed live for products, customers, customer transactions, and v2 orders; it was not probed for the other entities. The client refuses to re-anchor, and throws InvalidOperationException asking for a narrower ModifiedSince/ModifiedBefore window, when it cannot do so safely: records out of order, no paging timestamp returned, 50,000 records all within one millisecond, a window that would not advance, DK Plus ignoring the modified-after filter, or more than 2,000 records inside the boundary millisecond. Resources whose list endpoint has no modification filter (sales people, product units, and the work requests of one project) cannot re-anchor and throw the same exception before requesting a page past 50,000 records. Internal list methods that read every page (dimensions, project phases, tasks, and journals) re-anchor as well.
Direct page methods, where you pass the page and page size yourself (for example Customer.Transactions.GetPageAsync, Purchase.Vendor.GetPageAsync, Purchase.Invoice.GetProcessedAsync, Sales.Person.GetPageAsync, Project.Transaction.GetPageAsync, and GeneralLedger.Transaction.GetPageAsync), do not re-anchor. They reject page x count above 50,000 with ArgumentOutOfRangeException before any request is sent; narrow those calls with a modified-after/modified-before or other filter and start again from page 1.
| Resource | Portable filters |
|---|---|
| Customers | modified since, modified before, group |
| Products | identifier/item code, modified since, modified before, group |
| Employees | modified since, status |
| Projects | modified since, modified before, customer, group, status |
| Sales orders | identifier/number, customer, status, modified since, modified before |
| Sales invoices | identifier/record ID, customer, modified since, modified before |
| Sales quotes | identifier/number, customer, status, modified since, modified before |
| Recurring sales | identifier/number, customer, group, modified since, modified before |
| Dimensions | identifier as level 1-3, modified since, modified before |
| Sales people | no portable filters |
| Customer groups | modified since |
| Product groups | modified since |
| Product categories | modified since |
| Product units | no portable filters |
| Currencies | no portable filters |
| Payment modes, terms, and types | modified since |
Applying a filter outside this matrix throws NotSupportedException before an HTTP request is sent. The client never silently drops a requested filter.
IDkPlusClient.Customer.Query() returns ICustomerQuery and IDkPlusClient.Product.Query() returns IProductQuery. Both extend IDkPlusQuery<T, TSelf>, whose fluent members (ModifiedSince, ForGroup, Take, Include, Exclude, and the rest of IDkPlusQuery<T>) return the entity query, so the typed DK Plus filters chain in any order:
var customers = await client.Customer.Query()
.ForName("Jón") // sent as name=*jón*
.ForSalesPerson("S-7") // sent as salesperson=s-7
.Blocked(false)
.Include("Number", "Name")
.ToListAsync(cancellationToken);
| Query | Method | DK Plus parameter |
|---|---|---|
ICustomerQuery |
ForName, ForSalesPerson, ForCountry, ForPostalCode, ForPaymentTerm, ForPaymentMode, ForEmail |
name, salesperson, country, zipcode, paymentterm, paymentmode, email |
ICustomerQuery |
Blocked(bool), VatExempt(bool), Deleted(bool) |
blocked, novat, deleted |
IProductQuery |
ForAliasItemCode |
aliasItemCode |
IProductQuery |
Inactive(bool), OnWeb(bool), Deleted(bool) |
inactive, onweb, deleted |
The customer and product group and the product item code stay on the portable ForGroup and ForIdentifier. The customer attribute* and memo parameters are not exposed.
DK Plus matches these filters in a search index where the customer group, name, and email match only lowercase values, so the client normalizes the text with invariant-culture casing before sending it: the customer and product group, name, sales person, postal code, payment term, payment mode, and email are sent lowercase, and the country code uppercase. DK Plus ignores a name filter without *, so a name without * is sent as *value* (a contains search); a name with * is sent as written, lowercased, and a name of several words matches nothing. The alias item code is sent as written (it matches in either case). Live (2026-10-13), letter country codes such as IS currently match no customers while numeric codes do. Every filter is applied by DK Plus; the client never filters records itself. The parameters are sent with every page, kept when the query re-anchors past the paging limit, and are part of the continuation-token filter hash. A null, empty, or whitespace string throws ArgumentException before any request, and calling a filter again replaces its value. Whether Blocked(false) means "only unblocked" or "no restriction", and whether Deleted(true) means "only deleted" or "also deleted", is not verified live.
IExternalErpClient.Customer.Query() and .Product.Query() remain IErpQuery<Customer> and IErpQuery<Product>; the typed filters are DK-specific. A helper that returns IDkPlusQuery<T> loses the typed members, so reassigning a var that holds an ICustomerQuery from such a helper needs an explicit type.
Modified on the portable models is the dkERP modification time, but the modified-since and modified-before filters compare the timestamp DK Plus pages on. The library only forwards the window to DK Plus and never filters client-side, so a record whose Modified is older than the window can still be returned.
| DK Plus resource | Timestamp the window compares |
|---|---|
| Customers, products, vendors, sales invoices, customer transactions, orders, quotes, recurring sales, project work requests, project journals, project transactions, and General Ledger transactions | ObjectDate (label "Stored Date") |
| Every other entity: employees, projects, phases, tasks, dimensions, the reference lists (groups, categories, payment modes, terms, and types), vendor transactions, vendor invoices, and General Ledger accounts | Modified |
Generic tables (client.Table, devop-dk table) |
the table's own change time |
ObjectDate is a separate read-only member of the DK Plus subtypes and is no longer copied into Modified (changed in 0.4.0; see Models and writes).
Direct resource lookups use GetAsync where available. Prefer that method over forcing an identifier filter when the provider exposes a dedicated endpoint.
When the concrete client is typed as IDkPlusClient, every read resource returns the DK subtype, and resources whose DK Plus endpoints support both include and exclude return IDkPlusQuery<TDk> or an entity query derived from it: client.Customer.Query() is an ICustomerQuery (an IDkPlusQuery<DkPlusCustomer>), client.Product.Query() an IProductQuery (an IDkPlusQuery<DkPlusProduct>), client.Employee.Query() an IDkPlusQuery<DkPlusEmployee>, and so on. Use Include and Exclude with public model property names, including the properties declared only on the DkPlus* subtype; provider wire-field names are an implementation detail. The same resource is also an IErpReadResource<Customer, string>, so a portable consumer sees an IErpQuery<Customer> that returns the same instances typed as the portable base.
var customers = await client.Customer.Query()
.Include(
nameof(DkPlusCustomer.Number),
nameof(DkPlusCustomer.VatNumber),
nameof(DkPlusCustomer.IndustryClassificationCode),
nameof(DkPlusCustomer.Gender),
nameof(DkPlusCustomer.VatExempt),
nameof(DkPlusCustomer.ReceivablesAccount))
.Exclude(nameof(DkPlusCustomer.Blocked))
.ToListAsync(cancellationToken);
That is the ERPC-21 projection: Number, VatNumber, IndustryClassificationCode, Gender, VatExempt, and ReceivablesAccount are all DkPlusCustomer members and are sent as include=Number,ObjectDate,VATNumber,ISATNumber,Gender,NoVat,LedgerCode. The extra ObjectDate is the paging timestamp, which the client adds right after the identity (see below). They are not on the portable Customer, so nameof(Customer.VatNumber) no longer compiles.
Names are case-insensitive and can be passed as separate arguments or comma-separated values. Repeated calls accumulate fields and remove duplicates while preserving first-use order. The projection remains active for ToListAsync, AsAsyncEnumerable, and every ToPageAsync continuation request.
The client translates renamed and composed public properties to the required DK Plus fields. For example, DkPlusCustomer.NationalId becomes SSNumber, VatNumber becomes VATNumber, IndustryClassificationCode becomes ISATNumber, VatExempt becomes NoVat, ReceivablesAccount becomes LedgerCode, ReceiversActive becomes UseItemRecivers, Credit becomes Conditions, InvoiceDelivery becomes SendTo, ElectronicInvoicing becomes UBL, Receivers becomes the DK Plus Recivers field, and Address expands to the DK Plus address fields. Organized product properties expand in the same way: DkPlusProduct.InventorySettings, for example, selects all nine flat inventory-policy fields, including the wire-only spelling AllowNegativeInventiry. Vendors translate VatExempt to NoVat, PayablesAccount to LedgerCode, Approvers to Approve, BankAccount to BankInfo, and Dimensions to Dimmentions. A sales invoice Customer expands to the CNumber...CContact fields and Receiver to the IR* fields; a v2 sales document Customer selects the Customer object.
The record identity is automatically added to includes: Number for customers, vendors, employees, and v1 sales invoices, ItemCode for products, and Id (wire UId) for v2 sales orders, quotes, and recurring sales. For paged entities that page on ObjectDate, ObjectDate is added right after the identity, so a projection never stops a query from continuing past the paging limit; customer transactions force ID as identity. Excluding the identity or the paging timestamp throws before an HTTP request is sent. The raw Include/Exclude request APIs (vendor page, project list, work-request page, ledger-account list) follow the same identity rule: Number is always added to an include list and an exclude that names it throws ArgumentException; a response without its identity raises DkPlusContractException. Unknown, unavailable, and AdditionalData properties also fail locally with a list of selectable properties. Unavailable properties include Customer.Created (not provided by the customer endpoint) and, for sales invoices, Id, Status, and Deleted. The sensitive employee payroll fields (SpouseName, BankAccount, SalaryCode, PaymentFrequency, PaymentMode) are stripped from every employee read and never written; a projection may Exclude them so DK Plus does not send them, but Include rejects them.
Projection is available for customers, products, employees, sales invoices, customer transactions, vendors, customer groups, product groups, payment modes, payment terms, payment types, sales orders, sales quotes, and recurring sales. It is intentionally absent from fluent resources whose query endpoint does not support both operations, including projects, currencies, product categories, product units, dimensions, and sales people. Existing request-model APIs that already expose raw DK Plus Include or Exclude expressions are unchanged.
DkPlusProjections lists the accepted names per model type, for example for completion in a front end: IsSupported(typeof(DkPlusCustomer)), IncludableProperties(...) (every mapped public name in ordinal order, identity and paging timestamp included), and ExcludableProperties(...) (the includable names except the identity, the paging timestamp of a paged query, and properties that share their wire field, plus the exclude-only payroll names such as SpouseName). A model type without projection support, such as DkPlusProject, is unsupported and returns empty lists; nothing throws for it.
Portable consumers that hold an IErpQuery<Customer> cannot name DK members. Code that needs them injects IDkPlusClient and queries Customer.Query() directly, or downcasts a result to DkPlusCustomer.
Prefer GetAsync when a resource has a direct endpoint:
var customer = await client.Customer.GetAsync("C-100", cancellationToken);
var invoice = await client.Sales.Invoice.GetAsync("INV-100", cancellationToken);
var salesPerson = await client.Sales.Person.GetAsync("S-1", cancellationToken);
Each entity with a write endpoint uses one pattern. CreateAsync(TCreateRequest, cancellationToken) takes the full document, and UpdateAsync(key, TUpdateRequest, cancellationToken) takes a sparse request in which every member is nullable and the identity is the path argument. DK Plus ignores contacts, receivers, and memos sent in the customer document, so the customer requests have no such members; contacts and delivery receivers are written through client.Customer.Contact and client.Customer.Receiver:
await client.Customer.CreateAsync(new CustomerCreateRequest
{
Number = "C-100",
Name = "Example customer",
Group = "ALM"
}, cancellationToken);
await client.Customer.Contact.CreateAsync("C-100", new CustomerContactCreateRequest
{
Number = "1",
Name = "Accounts",
Email = "[email protected]"
}, cancellationToken);
await client.Customer.Receiver.CreateAsync("C-100", new CustomerReceiverCreateRequest
{
Number = "MAIN",
Name = "Main delivery"
}, cancellationToken);
await client.Customer.UpdateAsync("C-100", new CustomerUpdateRequest
{
Address = new Address { Lines = ["Example street 1"] },
Alias = ""
}, cancellationToken);
await client.Customer.Contact.UpdateAsync(
"C-100", "1", new CustomerContactUpdateRequest { Title = "CFO" }, cancellationToken);
Customer.Contact and Customer.Receiver also offer GetAllAsync(customer), GetAsync(customer, id) (null when missing), and DeleteAsync(customer, id); Customer.Receiver adds GetByNumberAsync and GetCustomersAsync for lookups by receiver number. An update that sets no member throws ArgumentException before any request. DK Plus reads can lag writes by a few seconds, so a read right after a create or update may not show the change yet. Which contact or receiver field the id argument matches is not verified live (the contact number is the likely candidate).
null means omitted, and omitted means unchanged. An empty string is sent as written, and an empty collection is sent as [] on update. Read models are never accepted as write input, and no compatibility overloads remain. Request types exist for customers, customer contacts and receivers, vendors, products, projects, project phases and tasks, work requests, sales orders, quotes, recurring sales, sales people, vendor invoices, General Ledger accounts and tax groups (create only), and (create only) sales invoices. See Models and writes for the sparse-update rules and what is not verified against a live company.
Project.DeleteAsync(number) and Project.DeleteByRecordIdAsync(recordId) delete a project through the generic table endpoint on the dkERP project table pjjob, because DK Plus has no project delete endpoint. They bypass DK Plus project validation, validate the number or record id before sending, and are never retried; what dkERP does with a project that has transactions, phases, or work requests is not verified live.
v2 sales documents are written head first, then line by line. Creating an order, quote, or recurring sale posts the head without lines, posts each line to the document's Line route, and re-reads the document; if a line fails, the new head is deleted again unless you supplied the document Number. A quote create with lines stops with InvalidOperationException when DK Plus returns the quote head without an id (a known DK Plus issue). An update sends the head without lines and then updates or adds each line you supplied; it never deletes lines and does not roll back, and an update that carries only lines skips the head request. See Sales documents.
Token creation is the exceptional authentication flow: it sends the supplied user credentials through request-scoped Basic authentication and never places them in pooled client defaults.
Invalid request models throw DkPlusModelValidationException before network I/O. A successful HTTP response that no longer matches the documented JSON contract throws DkPlusContractException with the operation, expected safe type name, JSON path, and redacted endpoint. The response body is never retained or logged.
See Sales documents, Vendors and inventory, the 0.4 upgrade guide, and API coverage.