DNN · Full C# client
This is the NeuroonClient that the DNN end-to-end recipe builds step by step, presented here as a single artifact ready to copy into the module. Compatible with DNN 9.x on .NET Framework 4.7.2+ (with System.Text.Json 6.0+) or .NET 8.
NuGet dependencies
<PackageReference Include="Polly" Version="8.*" />
<PackageReference Include="System.Net.Http.Json" Version="6.*" />
<PackageReference Include="System.Text.Json" Version="6.*" />
App_Code/Neuroon/NeuroonClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Polly;
using Polly.Retry;
public sealed class NeuroonClient
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _shopId;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retry = Policy
.HandleResult<HttpResponseMessage>(r => (int)r.StatusCode == 429 || (int)r.StatusCode >= 500)
.Or<HttpRequestException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
public NeuroonClient()
{
_http = new HttpClient
{
BaseAddress = new Uri(NeuroonSettings.ApiUrl),
Timeout = TimeSpan.FromSeconds(45)
};
_http.DefaultRequestHeaders.Add("X-Shop-API-Key", NeuroonSettings.ApiKey);
_http.DefaultRequestHeaders.Add("User-Agent", "DNN-Neuroon/1.0");
_shopId = NeuroonSettings.ShopId;
}
public async Task<SyncResult> SyncProductsAsync(SyncMode mode, IEnumerable<NeuroonProduct> products)
{
var aggregate = new SyncResult();
foreach (var batch in Chunk(products, 500))
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/products/sync",
new { syncType = mode.ToString(), products = batch },
JsonOpts));
resp.EnsureSuccessStatusCode();
aggregate.Merge(await resp.Content.ReadFromJsonAsync<SyncResult>(JsonOpts));
}
return aggregate;
}
public async Task TrackConversionAsync(string orderId, decimal orderValue, string currency,
IEnumerable<ConversionLine> lines)
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsJsonAsync(
$"/api/plugin/shops/{_shopId}/track/conversion",
new { orderId, orderValue, currency, conversions = lines },
JsonOpts));
resp.EnsureSuccessStatusCode();
}
public async Task<string> IssueWidgetTokenAsync()
{
var resp = await _retry.ExecuteAsync(() => _http.PostAsync(
$"/api/shops/{_shopId}/widget-token", content: null));
resp.EnsureSuccessStatusCode();
var payload = await resp.Content.ReadFromJsonAsync<WidgetTokenResponse>(JsonOpts);
return payload.Token;
}
public async Task<ShopInfo> GetShopInfoAsync()
{
var resp = await _retry.ExecuteAsync(() => _http.GetAsync("/api/plugin/shops/me"));
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<ShopInfo>(JsonOpts);
}
private static IEnumerable<List<T>> Chunk<T>(IEnumerable<T> source, int size)
{
var bucket = new List<T>(size);
foreach (var item in source)
{
bucket.Add(item);
if (bucket.Count == size) { yield return bucket; bucket = new List<T>(size); }
}
if (bucket.Count > 0) yield return bucket;
}
public enum SyncMode { INCREMENTAL, FULL }
public sealed class SyncResult
{
public int TotalReceived { get; set; }
public int NewProducts { get; set; }
public int UpdatedProducts { get; set; }
public int Skipped { get; set; }
public int Failed { get; set; }
public List<SyncError> Errors { get; set; } = new();
public int ProductsCount { get; set; }
public int RemainingProducts { get; set; }
public void Merge(SyncResult other)
{
TotalReceived += other.TotalReceived;
NewProducts += other.NewProducts;
UpdatedProducts += other.UpdatedProducts;
Skipped += other.Skipped;
Failed += other.Failed;
Errors.AddRange(other.Errors ?? Enumerable.Empty<SyncError>());
ProductsCount = other.ProductsCount;
RemainingProducts = other.RemainingProducts;
}
}
public sealed class SyncError
{
public string ExternalId { get; set; }
public string Error { get; set; }
}
public sealed class ConversionLine
{
public string ProductId { get; set; }
public string SearchLogId { get; set; } // required by the API (@NotBlank)
public int Quantity { get; set; }
public decimal LineTotal { get; set; }
}
public sealed class WidgetTokenResponse
{
public string Token { get; set; }
}
public sealed class ShopInfo
{
public string Id { get; set; }
public string Name { get; set; }
public string Plan { get; set; }
public int MaxProducts { get; set; }
public int ProductsCount { get; set; }
public int MaxSearchesPerMonth { get; set; }
public int SearchesThisMonth { get; set; }
}
}
Design notes
- Per-instance
HttpClient: in DNN you cannot reuse a globalHttpClientas trivially as in ASP.NET Core (noIHttpClientFactory). A new instance per scheduler run is acceptable because the scheduler does not run in tight-loop. - Single
AsyncRetryPolicy: covers429,5xxandHttpRequestException.2^nsecond backoff with 3 retries =2 + 4 + 8 = 14 smax. Enough to absorb short rate-limit windows. JsonNamingPolicy.CamelCase: the backend requirescamelCasekeys (syncType,externalId, etc.).WhenWritingNullavoids sending empty optional fields.- Specific
User-Agent: makes log filtering easier on the backend when diagnosing incidents. Timeout: 45 s: covers 500-product batches at p99 without hanging the AppPool.
How to use it
// Sync
var client = new NeuroonClient();
var result = await client.SyncProductsAsync(NeuroonClient.SyncMode.INCREMENTAL, products);
// Conversion
await client.TrackConversionAsync("ORD-1", 99.99m, "EUR", new[] {
new NeuroonClient.ConversionLine { ProductId = "SKU-1", SearchLogId = "log_aaa", Quantity = 1, LineTotal = 99.99m }
});
// Widget token
string token = await client.IssueWidgetTokenAsync();
// Shop info / quotas
var info = await client.GetShopInfoAsync();
Console.WriteLine($"Plan: {info.Plan}, products: {info.ProductsCount}/{info.MaxProducts}");
Tests
Keep a minimal WireMock.Net suite that validates:
SyncProductsAsyncchunks into batches of 500 when given 1,200 products._retryretries 3 times on429and then surrenders.IssueWidgetTokenAsyncparses{ "token": "..." }correctly (the response does not includeexpiresAt; TTL is fixed at 24 h).JsonNamingPolicyemitssyncType(notSyncType) in the body.
Next steps
scheduled-task— scheduler class that callsSyncProductsAsync.razor-snippets— widget embed and cart bridge.- Recipe · DNN end-to-end — full guide.