WinShopAPI docs
Guides

Product catalog sync

Mirror WinShop products, prices, variants and barcodes into an e-shop and keep them up to date incrementally.

Goal: keep a copy of the WinShop product catalog in another system, typically an e-shop. Do one full load, then pick up only what changed.

You will use:

  • POST /api/products/filter-paginated for the list, with prices
  • GET /api/products/product-details for variants and barcodes
  • a handful of item lists for names

Read Filtering and pagination first if you have not yet.

Before you start

Agree three things with the customer and put them in your configuration:

  • The price level. WinShop keeps 15 selling prices per product, called price levels. Which one is the e-shop price is the customer's decision. See Catalog.
  • Which products belong in the e-shop. All of them, or only some groups or seasons? The search filter can narrow it down.
  • What "removed" means. Usually an inactive product is hidden in the e-shop rather than deleted.

Setup

Every step below uses one small helper for authenticated JSON calls. The snippets build on each other, in page order. In C#, keep the record declarations of all snippets at the end of your file. GetAccessTokenAsync() and getAccessToken() are the token cache from Authentication.

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;

var http = new HttpClient { BaseAddress = new Uri("https://<your-instance>") };

async Task<T> GetAsync<T>(string path)
{
    using var request = new HttpRequestMessage(HttpMethod.Get, path);
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync());

    using var response = await http.SendAsync(request);
    response.EnsureSuccessStatusCode();
    return (await response.Content.ReadFromJsonAsync<T>())!;
}

async Task<T> PostAsync<T>(string path, object body)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync());

    using var response = await http.SendAsync(request);
    response.EnsureSuccessStatusCode();
    return (await response.Content.ReadFromJsonAsync<T>())!;
}

record Page<T>(List<T> Data, int CurrentPage, int TotalPages, int TotalCount, int PageSize,
    bool HasPreviousPage, bool HasNextPage);

The sync, step by step

Load the item lists

Products refer to colours, sizes and other reference values by id. Load those lists once per run and keep them in memory as lookup tables.

async Task<Dictionary<int, string?>> LoadNamesAsync(string list)
{
    var rows = await GetAsync<List<NamedRow>>($"/api/item-lists/{list}");
    return rows.ToDictionary(row => row.Id, row => row.Name);
}

var colors = await LoadNamesAsync("colors");
var sizes = await LoadNamesAsync("sizes");
var lengths = await LoadNamesAsync("lengths");
var dimensions = await LoadNamesAsync("dimensions");

record NamedRow(int Id, string? Name);

Page through the changed products

Search the product list for a window of lastModify, sorted by lastModify, 500 rows per page, and ask only for the fields you store.

  • lastModify is maintained by the server. It is set to the current time on every insert and every update of a product, so it is a reliable change marker. There is also createdAt.
  • filter.additional.lastModifyFrom and lastModifyTo bound the window. Both are inclusive: lastModify >= lastModifyFrom and lastModify <= lastModifyTo. For the first full load, leave lastModifyFrom out.
  • limitFields keeps the response small. Name the price fields of your price level: for level 1 that is price1 and price1WoVat (without VAT).
  • includeInactive: true asks for deactivated products too. They come with isInactive: true. You need them, otherwise you never learn that a product was switched off.
  • Sort by productId second, so that rows with the same timestamp have a fixed order.

Reading pages while the data changes

Other users keep working while you page. If a product on a page you have already read is saved again, its lastModify jumps to "now", it moves to the end of the list, and every row behind it shifts up by one. The first row of your next page is then never read.

Two rules together make the paging safe:

  1. Always set lastModifyTo to a moment that is already in the past on the instance. Then nothing can enter the window any more. A product that is saved during your run gets a newer lastModify and can only leave the window.
  2. Compare totalCount on every page with the first page. A product that leaves the window lowers totalCount. If the number changes, a shift happened: throw the pages away and read the window again.

The product that left is not lost. It now has a newer timestamp and belongs to the next run.

Do not take "a moment in the past" from your own clock, because it may differ from the clock of the instance. Ask the instance instead: the newest lastModify in the catalog is, by definition, not in its future. One row is enough, sorted descending. Step back one second from that value so that a save in the very same instant cannot slip into the window.

const int priceLevel = 1; // from your configuration

string[] fields =
[
    "productId", "productCode", "productName", "barcode", "groupName", "unitName", "vatRate",
    "isColorAndSize", "isInactive", "lastModify", $"price{priceLevel}", $"price{priceLevel}WoVat",
];

// The upper bound of a run: one second before the newest change on the instance.
async Task<DateTime?> GetUpperBoundAsync()
{
    var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
    {
        filter = new { basic = new { includeInactive = true } },
        pageNumber = 1,
        pageSize = 1,
        orderBy = new[] { "lastModify desc" },
        limitFields = new[] { "lastModify" },
    });

    return page.Data.FirstOrDefault()?["lastModify"]?.GetValue<DateTime>().AddSeconds(-1);
}

async Task<List<JsonObject>> LoadProductsAsync(DateTime? modifiedFrom, DateTime modifiedTo)
{
    while (true)
    {
        var products = new List<JsonObject>();
        int? expectedCount = null;
        var shifted = false;

        for (var pageNumber = 1; ; pageNumber++)
        {
            var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
            {
                filter = new
                {
                    basic = new { includeInactive = true },
                    additional = new { lastModifyFrom = modifiedFrom, lastModifyTo = modifiedTo },
                },
                pageNumber,
                pageSize = 500,
                orderBy = new[] { "lastModify", "productId" },
                limitFields = fields,
            });

            expectedCount ??= page.TotalCount;
            if (page.TotalCount != expectedCount)
            {
                shifted = true; // a product left the window: read the window again
                break;
            }

            products.AddRange(page.Data);
            if (!page.HasNextPage) break;
        }

        if (!shifted) return products;
    }
}

// the first full load: no lower bound
if (await GetUpperBoundAsync() is { } upperBound)
{
    var products = await LoadProductsAsync(modifiedFrom: null, upperBound);

    foreach (var product in products)
    {
        var id = product["productId"]!.GetValue<int>();
        var price = product[$"price{priceLevel}"]!.GetValue<decimal>();
        // upsert into your system by id
    }

    await SaveWatermarkAsync(upperBound);
}

SaveWatermarkAsync / saveWatermark and, later, ReadWatermarkAsync / readWatermark store and read one timestamp in your own storage. The watermark is the upper bound of the last run that succeeded.

A level price is the list price. If the e-shop should show the price one specific customer pays, including discounts and promotions, resolve it with Get products price instead.

Fetch variants and barcodes

The list row has one barcode and tells you with isColorAndSize whether the product is sold in variants. A variant is one combination of colour, size, length and dimension. The combinations and all the barcodes are in the product detail.

Fetch the details of the products you just loaded, in batches, with GET /api/products/product-details. Ask only for the sub-collections you need through details; here ColorSizeVariants and Barcodes. Keep the batches small enough for a URL, for example 50 ids.

async Task<List<ProductDetail>> LoadDetailsAsync(IEnumerable<int> productIds)
{
    var details = new List<ProductDetail>();

    foreach (var batch in productIds.Chunk(50))
    {
        // arrays repeat the key: productIds=1&productIds=2
        var query = string.Join("&", batch.Select(id => $"productIds={id}"));

        details.AddRange(await GetAsync<List<ProductDetail>>(
            $"/api/products/product-details?{query}&details=ColorSizeVariants&details=Barcodes"));
    }

    return details;
}

async Task StoreDetailsAsync(IEnumerable<int> productIds)
{
    foreach (var detail in await LoadDetailsAsync(productIds))
    {
        foreach (var variant in detail.ColorSizeVariants ?? [])
        {
            var color = variant.ColorId is { } colorId ? colors.GetValueOrDefault(colorId) : null;
            var size = variant.SizeId is { } sizeId ? sizes.GetValueOrDefault(sizeId) : null;
            // store the variant with its names
        }

        foreach (var barcode in (detail.Barcodes ?? []).Where(barcode => barcode.IsInvalid != true))
        {
            // store barcode.Barcode for the product, or for its variant when ColorId/SizeId are set
        }
    }
}

record ProductDetail(int ProductId, List<Variant>? ColorSizeVariants, List<ProductBarcode>? Barcodes);

record Variant(int Id, int? ColorId, int? SizeId, int? LengthId, int? DimensionId);

record ProductBarcode(int Id, string? Barcode, int? ColorId, int? SizeId, int? LengthId, int? DimensionId,
    bool? IsMain, bool? IsInvalid);

Run it incrementally

After the first full load, every run reads only the window since the last one.

  • The lower bound is the stored watermark minus one minute. The overlap covers a save that was still in flight when the previous run took its upper bound.
  • The upper bound is taken from the instance again, as in step 2, and becomes the new watermark when the run succeeded.
  • The overlap returns some products twice. That is harmless as long as you upsert by productId.

Timestamps are local time

lastModify is the wall-clock time of the instance without an offset, see Conventions. When the clocks go back in autumn one hour repeats and timestamps are briefly not in order, so run the comparison of step 5 after that night.

async Task SyncChangesAsync()
{
    if (await GetUpperBoundAsync() is not { } upperBound) return; // empty catalog

    DateTime? watermark = await ReadWatermarkAsync(); // null before the first full load
    var changed = await LoadProductsAsync(watermark?.AddMinutes(-1), upperBound);

    foreach (var product in changed)
    {
        // upsert by productId; hide the product when isInactive is true
    }

    // replaces the variants and barcodes of the changed products
    await StoreDetailsAsync(changed.Select(product => product["productId"]!.GetValue<int>()));

    // move the watermark only after everything above succeeded
    await SaveWatermarkAsync(upperBound);
}

How often to run depends on how fresh the e-shop has to be. Every few minutes is common for an incremental run that usually returns little.

Reconcile ids: inactive, deleted and missed products

Inactive. A product that is no longer sold is normally deactivated, not deleted. Deactivating is an update, so the product comes through the incremental run with isInactive: true. Hide it in the e-shop.

Deleted. A deleted product is simply gone. It cannot appear in a list of changes.

Missed. A product without a lastModify value never matches a lastModify window.

One comparison covers the last two. From time to time, for example once a night, load all product ids, which is cheap with limitFields, and compare them with the ids your system has:

  • An id you have but WinShop does not: the product was deleted. Remove it, but only after it was missing in two runs in a row. The id list is read with ordinary paging, so one run can, rarely, miss an id while products are being created and deleted.
  • An id WinShop has but you do not: load that product with filter.basic.productId and store it.
async Task<HashSet<int>> LoadAllProductIdsAsync()
{
    while (true)
    {
        var ids = new HashSet<int>();
        int? expectedCount = null;
        var shifted = false;

        for (var pageNumber = 1; ; pageNumber++)
        {
            var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
            {
                filter = new { basic = new { includeInactive = true } },
                pageNumber,
                pageSize = 5000,
                orderBy = new[] { "productId" },
                limitFields = new[] { "productId" },
            });

            expectedCount ??= page.TotalCount;
            if (page.TotalCount != expectedCount)
            {
                shifted = true; // products were created or deleted: read again
                break;
            }

            foreach (var row in page.Data) ids.Add(row["productId"]!.GetValue<int>());
            if (!page.HasNextPage) break;
        }

        if (!shifted) return ids;
    }
}

async Task ReconcileAsync(HashSet<int> knownIds)
{
    var existing = await LoadAllProductIdsAsync();

    foreach (var deletedId in knownIds.Except(existing))
    {
        // remove the product once it was missing in two runs in a row
    }

    foreach (var batch in existing.Except(knownIds).Chunk(500))
    {
        var page = await PostAsync<Page<JsonObject>>("/api/products/filter-paginated", new
        {
            filter = new { basic = new { includeInactive = true, productId = batch } },
            pageNumber = 1,
            pageSize = 500,
            orderBy = new[] { "productId" },
            limitFields = fields,
        });

        // upsert page.Data, then load their variants and barcodes
        await StoreDetailsAsync(batch);
    }
}

knownIds is the set of product ids your own system currently holds.

Checklist

  • Price level, product scope and the meaning of "removed" are agreed with the customer.
  • Every list call sets pageSize and limitFields.
  • Writes into your system are upserts keyed by productId.
  • Every lastModify window has an upper bound taken from the instance, and a run restarts when totalCount changes between pages.
  • The watermark is that upper bound, and it is moved only after a run succeeded.
  • A nightly id comparison catches deletions and anything the windows missed.
  • If a field name is rejected on one instance, check Fields differ between instances.

Reference

On this page