Viu.Emporix 0.12.4

dotnet add package Viu.Emporix --version 0.12.4
                    
NuGet\Install-Package Viu.Emporix -Version 0.12.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Viu.Emporix" Version="0.12.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Viu.Emporix" Version="0.12.4" />
                    
Directory.Packages.props
<PackageReference Include="Viu.Emporix" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Viu.Emporix --version 0.12.4
                    
#r "nuget: Viu.Emporix, 0.12.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Viu.Emporix@0.12.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Viu.Emporix&version=0.12.4
                    
Install as a Cake Addin
#tool nuget:?package=Viu.Emporix&version=0.12.4
                    
Install as a Cake Tool

Viu.Emporix

.NET SDK for the Emporix Commerce Engine. Targets .NET 10, is Native AOT compatible, and keeps dependencies to a minimum.

Not an official Emporix product. This SDK is built and maintained by viu. It is neither published nor supported by Emporix AG. «Emporix» is a trademark of its respective owner and is used here solely to describe compatibility.

Installation

dotnet add package Viu.Emporix

Quick start

With dependency injection

services.AddEmporix(options =>
{
    options.Tenant = "mytenant";

    // For server-side calls.
    options.Credentials.Backend = new EmporixServiceCredentials
    {
        ClientId = "...",
        Secret = "...",
    };

    // For anonymous catalog browsing.
    options.Credentials.Storefront = new EmporixStorefrontCredentials
    {
        ClientId = "...",
        Context = new EmporixStorefrontContext
        {
            Currency = "CHF",
            SiteCode = "main",
            TargetLocation = "CH",
        },
    };
});

Then take the EmporixClient:

public sealed class CatalogController(EmporixClient emporix)
{
    public async Task<IActionResult> Get(string id, CancellationToken cancellationToken)
    {
        var product = await emporix.Products.GetAsync(id, cancellationToken: cancellationToken);
        return product is null ? NotFound() : Ok(product);
    }
}

An incomplete configuration fails at application startup, not on the first call.

Without a container

using EmporixClient client = new(new EmporixOptions
{
    Tenant = "mytenant",
    Credentials = { Storefront = new EmporixStorefrontCredentials { ClientId = "..." } },
});

await foreach (var product in client.Products.ListAllAsync())
{
    Console.WriteLine(product.Code);
}

Authentication

What a call is authorised with lives in the call, not on the client. That is why one instance safely serves many concurrent users — including a server acting for a different customer on every request.

Call Who owns the token Used for
AuthContext.Anonymous() the SDK browsing the catalog without a sign-in
AuthContext.Service() the SDK server-side writes
AuthContext.Service("partner") the SDK a named credential set
AuthContext.Customer(token) the calling application signed-in customers, personalised prices
AuthContext.Raw(token) the calling application tokens from SSO or token exchange

When omitted, every method picks the fitting default: reads go anonymous, writes use a service token.

// Anonymous — the default.
var page = await client.Products.ListAsync();

// With a customer token, for personalised prices.
var mine = await client.Products.GetAsync("p1", AuthContext.Customer(customerToken));

// Server-side write.
await client.Products.CreateAsync(product);

For tokens the SDK owns, a 401 triggers one fresh token and a retry. A customer token belongs to the calling application; its 401 is passed through — unless an ICustomerTokenRefresher is registered.

Environments

Emporix separates environments by tenant, not by host. Every one of the 44 vendored specifications points at https://api.emporix.io; there is no staging host. Dev, staging and production are three tenants with three sets of credentials, and Host stays at its default unless you are pointing at a proxy or a mock server.

Which means dev and production are one string apart. Everything below is about making that string hard to get wrong.

Configuration per environment

Bind the options from configuration and let the layering .NET already has do the work:

builder.Services.AddEmporix(builder.Configuration.GetSection("Emporix"));
// appsettings.json — what is the same everywhere
{
  "Emporix": {
    "Credentials": {
      "Storefront": {
        "ClientId": "...",
        "Context": { "Currency": "CHF", "SiteCode": "main", "TargetLocation": "CH" }
      }
    }
  }
}

// appsettings.Development.json — only what differs
{ "Emporix": { "Tenant": "acme-dev" } }

The section's shape is EmporixOptions, so anything on it can be configured this way — timeouts, retry, the token cache, and the named credential sets under Credentials:Custom:<name>.

To take everything from configuration and still override one value in code, bind first and adjust after; the calls apply in order:

builder.Services.AddEmporix(builder.Configuration.GetSection("Emporix"));
builder.Services.Configure<EmporixOptions>(o => o.Tenant = ResolveTenant());

The delegate form shown under Quick start stays the right choice when the values come from somewhere that is not IConfiguration — flat environment variables of your own naming, for instance, as samples/Viu.Emporix.Storefront does.

Secrets belong in none of those files

Credentials.Backend.Secret is the one value that must not sit in a file in your repository. Locally use user secrets; deployed, use an environment variable or a vault. The .NET configuration stack layers them for you — this wins over any JSON file, with no code change:

Emporix__Credentials__Backend__Secret=...

The storefront client id is a different matter: anonymous sessions need no secret, so that one may ship inside a browser bundle.

Two mistakes worth designing against

A misconfigured deployment should not start. AddEmporix registers ValidateOnStart, so a missing or malformed tenant fails at startup rather than on the first API call, in production, as an empty product list. That guarantee holds for both overloads.

Log the tenant once, at startup. The SDK never logs tokens, but nothing tells you which tenant a running process is talking to. Without that line, «why are there no products here» takes hours to separate from «wrong tenant»:

app.Logger.LogInformation(
    "Emporix tenant {Tenant}", app.Services.GetRequiredService<IOptions<EmporixOptions>>().Value.Tenant);

Several environments in one process

For a migration or a sync job that talks to two tenants at once, register a client per environment as a keyed singleton:

foreach (string name in new[] { "dev", "prod" })
{
    EmporixOptions options = builder.Configuration
        .GetSection($"Emporix:{name}").Get<EmporixOptions>()!;

    builder.Services.AddKeyedSingleton(name, (sp, _) =>
        new EmporixClient(options, sp.GetRequiredService<ILoggerFactory>()));
}

public sealed class Migration(
    [FromKeyedServices("dev")] EmporixClient source,
    [FromKeyedServices("prod")] EmporixClient target);

AddEmporix itself registers exactly one configuration, so this is the way for now. Keep each client a singleton: one built through its public constructor owns its own connection pool, and building one per request exhausts sockets the same way new HttpClient() in a loop does.

Credentials.Custom is not for environments

It looks like it might be. It is a set of named client credentials within one tenant, addressed per call:

await client.Imports.StartRunAsync(configId, auth: AuthContext.Service("import-writer"));

Useful for least privilege — a writing client for the import job, a read-only one for the dashboard. It cannot reach another tenant, because the tenant lives on the options and not on the AuthContext.

Mixins

A mixin is a set of tenant-defined fields under entity.mixins.<key>, described by a JSON Schema that Emporix versions for you. The SDK reads and writes them typed, and filters on them:

var delivery = MixinReader.Read(product.Mixins, Mixins.DeliveryOptions);

var w = MixinWriter.Create()
    .Set(Mixins.DeliveryOptions, new DeliveryOptionsMixinV6 { Packaging = "Paper" });

product.Mixins          = w.Values;
product.Metadata.Mixins = w.SchemaUrls;   // Emporix leaves a mixin unvalidated without this

string q = MixinQuery.For(Mixins.DeliveryOptions)
    .Where(d => d.Packaging, Condition.EqualTo("Paper"))
    .Where(d => d.Weight, Condition.AtLeast(2))
    .Build()
    .Build();

await client.Products.SearchAsync(q);

The condition decides which operators an attribute accepts, so Condition.AtLeast on a text attribute is a compile error rather than a query the backend rejects. Or needs compoundLogicalQuery, which only some services support, so it returns a type whose Build requires naming the endpoint:

a.Or(b).Build(EmporixQuery.ProductSearch);    // fine
a.Or(b).Build(EmporixQuery.CategorySearch);   // throws — Category cannot run it
a.Or(b).Build();                              // does not compile

The types come from your tenant, so they are generated into your repository:

dotnet tool install --global Viu.Emporix.MixinSync

emporix-mixins pull && emporix-mixins generate    # commit the output
emporix-mixins check                              # for CI; exits 1 on drift

emporix-mixins.json sits beside your solution; credentials come from EMPORIX_BACKEND_CLIENT_ID and EMPORIX_BACKEND_SECRET, so the file carries nothing secret:

{
  "tenant": "acme",
  "namespace": "Acme.Mixins",
  "out": "src/Acme.Shop/Mixins/Generated",
  "lockFile": "src/Acme.Shop/Mixins/mixins.lock.json"
}

check is the part worth automating. Emporix assigns a new schema version on every change, so put this in your own repository:

on:
  schedule: [{ cron: "0 6 * * *" }]
  workflow_dispatch: {}
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v5
      - run: dotnet tool install --global Viu.Emporix.MixinSync
      - run: emporix-mixins pull && emporix-mixins generate
        env:
          EMPORIX_BACKEND_CLIENT_ID: ${{ secrets.EMPORIX_BACKEND_CLIENT_ID }}
          EMPORIX_BACKEND_SECRET: ${{ secrets.EMPORIX_BACKEND_SECRET }}
      - uses: peter-evans/create-pull-request@v8
        with:
          title: "chore: sync mixin schema versions"
          branch: mixins/sync

A raised version then arrives as a pull request with the type diff beside it.

Five q forms come from the Node SDK and are not yet verified against a live tenant — the range syntax, the localized path, exists/missing semantics, whitespace escaping, and whether metadata.mixins must be resent on PATCH. They are listed in the design spec.

Products

// Single item.
var product = await client.Products.GetAsync("p1");
var byCode = await client.Products.GetByCodeAsync("COFFEE-500");

// One page.
var page = await client.Products.ListAsync(new ProductPageOptions
{
    PageNumber = 1,
    PageSize = 60,
    IncludeTotalCount = true,
});

// Everything, one page fetched at a time.
await foreach (var item in client.Products.ListAllAsync())
{
    // …
}

// Search.
var found = await client.Products.SearchAsync("productType:BASIC");
var byName = await client.Products.SearchByNameAsync("coffee");

// Many at once — split into chunks automatically.
var many = await client.Products.GetManyByIdAsync(ids);

// Variants of a parent product.
await foreach (var variant in client.Products.ListVariantsAsync("parent-1"))
{
    // …
}

// Writes.
var created = await client.Products.CreateAsync(newProduct);
await client.Products.UpdateAsync("p1", changes);
await client.Products.DeleteAsync("p1", force: true);

Bundles, variants and the other product shapes

Emporix returns five shapes from a product read — basic, bundle, parent variant, variant and dynamic variant — and the specification declares them as a union with no discriminator. The methods above return the basic shape for all five, which is right for a catalogue of plain products. Where bundles or variants matter, AnyType resolves them:

var product = await client.Products.AnyType.GetAsync(id);

if (product is BundleProductWithId bundle)
{
    foreach (var item in bundle.BundledProducts)
        Console.WriteLine($"{item.ProductId} x{item.Amount}");
}
else if (product is VariantProductWithId variant)
{
    Console.WriteLine($"a variant of {variant.ParentVariantId}");
}

Every read exists there — GetAsync, GetByCodeAsync, ListAsync, ListAllAsync, SearchAsync, SearchByNameAsync, GetManyByIdAsync, GetManyByCodeAsync and ListVariantsAsync — with identical parameters, so a mixed search resolves each result on its own. The two walkers resolve per element rather than per page:

await foreach (var product in client.Products.AnyType.ListVariantsAsync("shirt"))
{
    var variant = (VariantProductWithId)product;
    Console.WriteLine(variant.ParentVariantId);
}

That one is worth singling out. Its filter pins productType to VARIANT, so every result is known to be a variant — and the plain ListVariantsAsync still returns the basic shape, leaving parentVariantId reachable only through the extension data.

Nothing is lost through the plain methods either: unknown fields land in the AdditionalProperties extension data and can be read from there. AnyType gives you the typed path instead of that.

One limitation: the specification does not require productType on a variant. A variant sent without it resolves to the basic shape. Deriving the type from other fields would be guessing, so the SDK does not.

A productType the vendored specification does not list resolves to the basic shape too, and reads back as null rather than throwing — see «Values the specification has not caught up with».

Writing a bundle or a variant

The write calls take the same five shapes. CreateAsync, ReplaceAsync, CreateManyAsync and UpdateManyAsync accept whichever one you pass:

await client.Products.CreateAsync(new BundleProductCreation
{
    Code = "gift-box",
    BundledProducts = { new BundledProduct { ProductId = "p1", Amount = 2 } },
});

await client.Products.CreateAsync(new VariantProductCreation
{
    Code = "shirt-red-m",
    ParentVariantId = "shirt",
});

A bulk call may mix them, which is what the specification's array of oneOf permits:

await client.Products.UpdateManyAsync(
[
    new BasicProductBulkUpdate { Id = "p1", Published = true },
    new BundleProductBulkUpdate
    {
        Id = "g1",
        BundledProducts = { new BundledProduct { ProductId = "p2", Amount = 1 } },
    },
]);

PATCH is the exception, and it is not per type. The specification declares one flat schema there, so UpdateAsync takes ProductPartialUpdate — which carries the union of the type-specific fields, BundledProducts, VariantAttributes and Template included:

await client.Products.UpdateAsync("g1", new ProductPartialUpdate
{
    BundledProducts = [new BundledProduct { ProductId = "p2", Amount = 1 }],
});

Note the assignment rather than a collection initializer: BundledProducts is nullable on this type and carries a default instance on the bundle types, so = { … } compiles here and throws at runtime.

If you are coming from an earlier version, UpdateAsync is the one write call whose signature broke. It used to take BasicProductUpdate, which had no property for any of those fields — and which also sent productType, a field this body does not declare. Emporix was verified to discard that silently, so nothing you sent before was doing anything; the change costs you an edit and buys the fields you could not reach.

Localized text

Names and descriptions are LocalizedString, not string. Emporix returns the same field in two shapes — every translation when the request named no language, one text when it did — and this type reads both:

product.Name?.ToString()   // some text, whichever the response carried
product.Name?.Get("de")    // that language, or null
product.Name?.GetOrAny("de")   // that language, or any other rather than nothing

Set EmporixStorefrontContext.Language to have Emporix translate; leave it unset to get every language and choose per request.

The other services

Every Emporix service is covered, and all but one with the full set of operations the API offers for it — Availability carries the stock records but not the locations a site ships from, five operations that no caller has needed yet. The gap is pinned by a test rather than left to be discovered, so it is a gap someone chose. Each service hangs off the client under a name that says what it owns:

Property Covers
client.Products products, variants, search, bulk fetch
client.Categories categories, the tree, assignments
client.Brands brands
client.Labels labels
client.Catalogs catalogs and their categories
client.Carts carts, items, coupons, validation, command chains
client.Customers sign-up, sign-in, profile, addresses
client.Prices prices, price matching, price models and price lists
client.Availability stock per site
client.Checkout turning a cart into an order
client.Orders a customer's own orders
client.SalesOrders the administrative order collection
client.Media assets, links, downloads
client.Taxes tax configuration and calculation
client.Fees fees, and what they attach to
client.Coupons coupons, redemptions, referral coupons
client.Payments payment methods, and moving money
client.Shipping zones, methods, quotes, delivery times
client.Returns returns against an order
client.Invoices invoice generation, as a job
client.LegalEntities the companies a B2B tenant sells to
client.ContactAssignments who may act for which company
client.Locations where a company receives
client.CustomerAdmin customers, as a seller manages them
client.Approvals carts and quotes waiting for a decision
client.Quotes negotiated prices, before they become orders
client.Segments who gets which prices and which catalogue
client.Iam users, groups, and what they may do
client.Schemas schemas and custom entities
client.Sites the storefronts a tenant runs
client.Vendors who sells, in a marketplace
client.Currencies currencies and exchange rates
client.Countries countries and regions
client.Webhooks Emporix calling out when something happens
client.Units units of measure, and converting between them
client.SequentialIds order numbers and the like
client.Configuration tenant and client configuration
client.SessionContext what a session carries beyond its token
client.Imports bringing data in from somewhere else
client.Indexing which provider indexes the catalogue, and rebuilding it
client.Search search over custom entities, saved searches, their indexes, and copying index configuration between tenants (preview)
client.ShoppingLists what a customer means to buy later
client.RewardPoints loyalty points, and what they buy
client.Ai text generation, and agents that do things
client.RagIndexer what an agent can retrieve over
client.CloudFunctions code a tenant deployed, invoked by name
client.AuditLogs who changed what, when, and from which value to which

A storefront flow from browsing to order:

var shopper = AuthContext.Anonymous();

// What does this person pay right now? Currency, site and country come from
// the token, which is why matching needs a shopper context.
var prices = await client.Prices.MatchByContextChunkedAsync(items, auth: shopper);

// Is it in stock?
var stock = await client.Availability.GetAsync("p1", "main");

// Cart. A cart belongs to a person, so a service token is refused.
var cart = await client.Carts.GetCurrentAsync(
    new CurrentCartQuery { SiteCode = "main", Create = true },
    shopper);

// Adding answers with the new item's id and YRN, not with its price — that
// takes GetItemAsync.
var added = await client.Carts.AddItemAsync(cart!.Id, item, shopper);

// Several cart operations in one request: the write and the calculated cart.
var chain = await client.Carts.ExecuteAsync(
    cart.Id,
    [CartCommand.AddCartItem(item), CartCommand.GetCart()],
    shopper);
var calculated = chain.Results.Last().ReadCart();

// Order. Deliberately never retried — a repeated checkout is a second order.
var order = await client.Checkout.PlaceOrderAsync(checkout, shopper);

// Afterwards, from the customer's own token.
var mine = await client.Orders.ListMineAsync(AuthContext.Customer(token));

Some services group operations that belong together:

client.Prices.Models · client.Prices.Lists price models, price lists and their prices
client.Products.Templates the attribute sets products are built from
client.Categories.Assignments what sits in a category, also by reference
client.Customers.Addresses a customer's own addresses and their tags
client.Payments.Modes what a tenant accepts, and the reduced view a browser may see
client.Shipping.ForSite(site) zones, methods, quotes, groups — everything per site
client.Shipping.DeliveryTimes delivery times and slots, configured tenant-wide
client.Coupons.Redemptions(code) the redemptions of one coupon
client.Fees.ForItem(yrn) · client.Fees.ForProduct(id) the fees attached to one thing
client.Segments.Customers(id) · client.Segments.Items(id) who is in a segment, and what it grants
client.Quotes.Reasons why a quote was requested or refused
client.CustomerAdmin.AddressesOf(number) one customer's addresses, seller-side
client.Iam.Users · client.Iam.Groups · client.Iam.AccessControls identity, membership and grants
client.Schemas.CustomEntities · client.Schemas.InstancesOf(type) a tenant's own shapes, and their records
client.Search.ForType(type) one custom type's search, saved searches and indexes
client.Sites.MixinsOf(code) a tenant's own settings on one site
client.Configuration.ForClient(id) configuration narrowed to one client
client.Ai.Agents · client.Ai.Templates agents, and the templates they are built from
client.Ai.Tools · client.Ai.McpServers what an agent may reach out to
client.Ai.Tokens · client.Ai.OAuths the credentials those need
client.Ai.Conversations · client.Ai.Logs · client.Ai.Jobs what was said, what was done, what is still running

A cart item is addressed by its YRN, not by a bare product id — ProductYrn.Create(tenant, id) builds one, and passing a bare id is refused before the request leaves. It also needs the priceId from a price match: Emporix rejects an item without one.

Several services refuse the wrong kind of token instead of failing quietly. Price matching with a service token would return an empty list — indistinguishable from «no prices configured» — so it throws EmporixConfigurationException before the request is sent. Carts, checkout and own-orders do the same.

Where the SDK hands you JSON instead of a type

Three places, all for the same reason: the specification declares a oneOf with no discriminator the generator can act on, so choosing one of the alternatives would silently drop the others' fields.

  • client.Ai.Tools — a tool is a Slack, Teams or one of two retrieval kinds. Writing is typed, one method per kind; reading hands back a JsonElement whose type says which it is.
  • client.Ai.McpServers — the same split between a server Emporix hosts and one you run yourself.
  • client.CloudFunctions — no specification exists at all, by design. The caller supplies the type information; see ADR-0009.

Everything else returns a generated type. The full picture is in docs/analysis.md; how the coverage was built up, wave by wave, is in docs/roadmap.md.

Waiting for work that takes a while

Imports, reindexing, invoice generation and asynchronous chats all answer with a job rather than a result. EmporixPolling.WaitForAsync waits for one, with a growing interval and a timeout that is distinct from cancellation:

var job = await EmporixPolling.WaitForAsync(
    poll: ct => client.Imports.GetRunAsync(runId, cancellationToken: ct),
    isComplete: r => r?.Run?.Status is not
        (ImportRunStatus.QUEUED or ImportRunStatus.RUNNING or ImportRunStatus.RESUMABLE),
    cancellationToken: cancellationToken);

There is no job type, because the four job shapes in the API share no field a type system can use — ADR-0008 has the detail. Two endpoints stream their progress instead of being polled; client.Imports.StreamEventsAsync and client.Ai.ChatStreamAsync return the response unread, and System.Net.ServerSentEvents — already in the net10.0 shared framework — parses it in three lines (ADR-0007).

Values the specification has not caught up with

The vendored specifications declare 240 enums, and Emporix can add a value to one of them at any time. When that happens the SDK reads the field as null rather than failing:

// productType: "SOMETHING_NEW" — a value this copy of the specification lacks
var product = await client.Products.GetAsync(id);

product.Id           // "b1"      — the rest of the object is intact
product.ProductType  // null      — «not a value I know»

The alternative was worse than it sounds. Before this, one unrecognised value threw and took the whole response with it — a page of sixty products lost because one of them had a field the vendored copy did not list.

Two limits worth knowing:

  • null means «absent» and «unrecognised» alike. Neither leaves you a usable value, and neither is sent back: a null enum is omitted from every write, so it cannot clear a field by accident.
  • Fields the specification marks required stay strict. There is nowhere to put an absence in a non-nullable enum, and mapping to the first member would turn an unreadable value into a wrong one. Those still throw.

The reasoning, and the three alternatives rejected, are in ADR-0010.

Error handling

Every failure derives from EmporixException. The two main branches separate «Emporix responded» from «the request never arrived»:

try
{
    await client.Products.GetAsync(id);
}
catch (EmporixNotFoundException)
{
    // 404
}
catch (EmporixInsufficientScopeException ex)
{
    // 403 — ex.RequiredScope names the missing scope.
}
catch (EmporixRateLimitException ex)
{
    // 429 — ex.RetryAfter carries the server's requested wait.
}
catch (EmporixApiException ex)
{
    // Any other response with an error status.
    logger.LogError("{Status} {Code} {CorrelationId}", ex.StatusCode, ex.ErrorCode, ex.CorrelationId);
}
catch (EmporixTransportException)
{
    // Timeout or network problem — there was no response.
}

Every failure carries a CorrelationId that the SDK sent along. Quote it in support requests.

A response body that is not JSON — an HTML error page from an upstream proxy, say — never turns into a serialization failure: it is available verbatim in RawBody.

Retries

5xx and 429 responses are retried with a growing wait and jitter, capped at 8 seconds. A server-supplied Retry-After takes precedence.

POST and PATCH are not retried unless the call explicitly declares itself repeatable. A server error can arrive after the server has already applied the change — a repeated order would be a duplicate order.

Samples

Project What it shows
samples/Viu.Emporix.Sample the smallest thing that works — no container
samples/Viu.Emporix.Storefront an ASP.NET Core backend: AddEmporix, an AuthContext per request, SDK failures mapped to status codes in one place
samples/Viu.Emporix.SmokeTest the anonymous flow against a real tenant (see below)

The storefront sample is the shape most consumers will build, and it publishes Native AOT like the rest — being trimmable in a console app would prove little about the host the SDK actually runs in.

EMPORIX_TENANT=your-tenant EMPORIX_CLIENT_ID=your-client-id EMPORIX_SITE=main EMPORIX_CURRENCY=CHF dotnet run --project samples/Viu.Emporix.Storefront

Before releasing: the smoke test

Unit tests use a stubbed HttpMessageHandler, which cannot tell a right address from a wrong one — that is how six calls in this SDK once pointed at endpoints Emporix does not have, each with a passing test. SpecPathTests now catches that against the vendored specifications, and SpecResponseTests a response read into a type the specification does not declare. Only a real call catches a body the API rejects, a response the specification itself gets wrong, or a scope a token turns out not to carry.

samples/Viu.Emporix.SmokeTest walks the anonymous storefront flow against a real tenant — session, products, categories, prices, availability, cart, item, validation — and exits non-zero if any step fails. It creates one cart and deletes it again; it places no order.

It has already earned its keep: the first run against a live tenant broke on the first call and turned up seven defects, among them product names that could not be parsed at all, a price match that returned the request type instead of the response, and three places where the specification disagrees with the API.

EMPORIX_TENANT=your-tenant EMPORIX_CLIENT_ID=your-client-id EMPORIX_SITE=main EMPORIX_CURRENCY=CHF EMPORIX_COUNTRY=CH EMPORIX_PRODUCT_ID=a-priced-product dotnet run --project samples/Viu.Emporix.SmokeTest

EMPORIX_PRODUCT_ID is worth setting: a tenant can list plenty of products and have prices for none of them, and then the pricing and cart-item steps skip themselves and prove nothing. Naming one product that is priced walks the flow to the end. EMPORIX_HOST overrides the API host; everything else is optional.

Credentials come from the environment and are never read from a file in the repository. Nothing it prints contains a token.

The seller's side

An anonymous storefront token cannot reach anything a seller configures, so the smoke test has a second pass that runs on client credentials. It is read-only — every call is a GET or a search — and it covers the services no browser ever sees: taxes, sites, shipping zones, IAM, custom entities, imports, indexing, reward options, AI agents and tools, shopping lists and the audit log.

A third pass writes, and exists because the read-only one could not: it creates a media asset, patches it, attaches a reference, detaches it and deletes the asset again, reading back after each step because this service answers 204 to writes it discards. Attaching an asset to a product had been broken since the day it shipped, and nothing had noticed. Every step verifies its own effect, and the delete runs even when a step before it fails.

EMPORIX_BACKEND_CLIENT_ID=your-client-id EMPORIX_BACKEND_SECRET=your-secret dotnet run --project samples/Viu.Emporix.SmokeTest

Without those two the pass says so and skips itself, which is why the same command works with or without them. Put the secret in the environment or a repository secret — never in a file in the repository.

Releasing

Releases are cut by Release Please: every push to main updates a release pull request, and merging it publishes to nuget.org. What that asks of a contributor is one thing — a commit message a machine can classify:

feat: add the audit log service
fix(cart): send the item YRN rather than the bare id

feat moves the minor version, fix the patch, and chore/test/ci move nothing. The full picture, including what is deliberately still manual, is in docs/releasing.md.

Contributing

CONTRIBUTING.md covers the commit convention, the pull-request flow and the handful of mistakes that cost time in this repository. The one worth repeating here: the types under src/Viu.Emporix/Generated/ are produced from the Emporix specifications and are not edited by hand:

dotnet tool restore
dotnet run --project tools/Viu.Emporix.SpecSync

That downloads the specifications, applies the documented repairs and regenerates the types. Adjustments belong in the pipeline or in a separate file alongside.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.12.4 64 10/22/2026
0.12.3 84 10/20/2026
0.12.2 89 10/19/2026
0.12.1 85 10/14/2026
0.12.0 82 10/14/2026
0.11.2 88 10/14/2026
0.11.1 91 10/9/2026
0.11.0 90 10/9/2026
0.10.0 93 10/8/2026
0.9.2 90 10/7/2026
0.9.1 91 10/2/2026
0.9.0 95 10/1/2026
0.8.0 99 10/1/2026
0.7.0 105 9/28/2026
0.6.0 118 9/11/2026
0.5.0 109 9/10/2026
0.4.2 103 9/9/2026
0.4.1 121 9/7/2026
0.4.0 110 9/4/2026
0.3.3 111 9/3/2026
Loading failed