Recipe: DNN end-to-end
This guide is for integrators working with DNN Platform (formerly DotNetNuke) and a .NET-based e-commerce module (DNN Commerce, NB_Store, Hotcakes or a custom module). When you finish you will have:
- Products synced from your DB to Neuroon (nightly sync + delta).
- Search widget rendered in your theme, with token rotated server-side.
- Server-to-server conversion tracking on order close.
Estimated time: 45-60 min the first time.
Prerequisites
- DNN 9.x on .NET Framework 4.7.2+ or DNN on .NET 8 (modern modules).
- Access to the products module DB (direct SQL query or the module repository).
- Host permissions to add HostSettings and a DesktopModule.
- A Shop API Key generated from the Neuroon dashboard (Production or Development).
- NuGet:
Polly(retry) andSystem.Net.Http.Json(on .NET Framework you needSystem.Text.Json6.0+).
Step 1. Store credentials in HostSettings
DNN offers HostController.Instance.Update("Key", "Value", true) to store encrypted settings. Create four entries:
using DotNetNuke.Entities.Host;
HostController.Instance.Update("Neuroon.ShopId", "shop_xxxxxxxx", true);
HostController.Instance.Update("Neuroon.ApiKey", "sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true);
HostController.Instance.Update("Neuroon.ApiUrl", "https://dev-api.neuroon.ai", true);
HostController.Instance.Update("Neuroon.WidgetTokenCache", "", true);
And a helper to read them:
public static class NeuroonSettings
{
public static string ShopId => HostController.Instance.GetString("Neuroon.ShopId");
public static string ApiKey => HostController.Instance.GetEncryptedString("Neuroon.ApiKey", Config.GetDecryptionkey());
public static string ApiUrl => HostController.Instance.GetString("Neuroon.ApiUrl", "https://api.neuroon.ai");
}
Step 2. Reusable HTTP client
Create a NeuroonClient with retry and idempotency. Place the file in the module, e.g. 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;
}
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 int Quantity { get; set; } public decimal LineTotal { get; set; } }
public sealed class WidgetTokenResponse { public string Token { get; set; } }
}
Step 3. Mapping from your DB
Adapt this DTO to your model. The structure matches the products/sync endpoint:
public sealed record NeuroonProduct
{
public string ExternalId { get; init; } // your PK / SKU
public string Sku { get; init; }
public string Name { get; init; } // max 512
public string Description { get; init; } // max 5000
public decimal Price { get; init; }
public string Currency { get; init; } = "EUR"; // ISO 4217
public string Url { get; init; }
public string ImageUrl { get; init; }
public IReadOnlyList<string> Categories { get; init; }
public IReadOnlyList<string> Brands { get; init; }
public bool? InStock { get; init; }
public int? StockQuantity { get; init; }
public decimal? Rating { get; init; }
public int? ReviewCount { get; init; }
public bool? IsActive { get; init; } = true;
public IReadOnlyList<string> Tags { get; init; }
public decimal? SalePrice { get; init; }
public string ParentExternalId { get; init; }
public IReadOnlyDictionary<string, string> Attributes { get; init; }
}
Validations applied by the backend (reference): externalId, name, price, url are required; name max 512, description max 5000, price greater than or equal to 0, currency ISO 4217, rating between 0 and 5.
Step 4. Nightly sync as IScheduledTask
DNN runs scheduled tasks via the Scheduler. Create a class that inherits from SchedulerClient:
using DotNetNuke.Services.Scheduling;
public class NeuroonSyncScheduler : SchedulerClient
{
public NeuroonSyncScheduler(ScheduleHistoryItem oItem) : base() { ScheduleHistoryItem = oItem; }
public override void DoWork()
{
try
{
Progressing();
var client = new NeuroonClient();
var products = LoadProductsFromCommerceModule();
var result = client.SyncProductsAsync(NeuroonClient.SyncMode.INCREMENTAL, products)
.GetAwaiter().GetResult();
ScheduleHistoryItem.AddLogNote(
$"Synced {result.NewProducts} new, {result.UpdatedProducts} updated, " +
$"{result.Failed} failed, remaining quota {result.RemainingProducts}");
ScheduleHistoryItem.Succeeded = true;
}
catch (Exception ex)
{
ScheduleHistoryItem.Succeeded = false;
ScheduleHistoryItem.AddLogNote("Neuroon sync failed: " + ex.Message);
Errored(ref ex);
}
}
private static IEnumerable<NeuroonProduct> LoadProductsFromCommerceModule()
{
// NB_Store example (adapt to your module):
// foreach (var p in NBrightBuy.GetActiveProducts(portalId)) yield return Map(p);
throw new NotImplementedException("Implement the mapping from your e-commerce module.");
}
}
Register the task with frequency 1 day from Host → Schedule → Add Item. For a more frequent delta sync (e.g. every hour), filter products by LastUpdatedDate > DateTime.UtcNow.AddHours(-1).
Step 5. Issue widget tokens server-side
The Widget Token lives 24 h and is injected into the frontend <script>. To avoid hitting the API on every request, cache the token:
public static class WidgetTokenProvider
{
private static readonly object Lock = new();
private static string _token;
private static DateTime _expires;
public static string Get()
{
lock (Lock)
{
if (_token != null && DateTime.UtcNow < _expires.AddMinutes(-5))
return _token;
var fresh = new NeuroonClient().IssueWidgetTokenAsync().GetAwaiter().GetResult();
_token = fresh;
_expires = DateTime.UtcNow.AddHours(24);
return _token;
}
}
}
Step 6. Embed in the theme (Skin / SkinObject)
In your Skin's .ascx file (or as a reusable SkinObject), add:
<%@ Control Language="C#" AutoEventWireup="true" %>
<%@ Register TagPrefix="dnn" Namespace="DotNetNuke.UI.Skins.Controls" Assembly="DotNetNuke" %>
<asp:PlaceHolder runat="server">
<div id="neuroon-search"></div>
<script
src="https://cdn.neuroon.ai/widget.js"
data-token='<%= WidgetTokenProvider.Get() %>'
data-container="#neuroon-search"
data-theme="auto"
data-locale='<%= System.Threading.Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName %>'
data-api-url='<%= NeuroonSettings.ApiUrl %>'
crossorigin="anonymous">
</script>
</asp:PlaceHolder>
For production, pin an exact version and add integrity with the SRI hash published at https://cdn.neuroon.ai/widget@VERSION/sri-manifest.json.
Step 7. Cart bridge (sync the cart with the widget)
The widget listens to neuroon:cart-update events and adjusts its responses (cross-sell, "already in cart" validations). In your cart view:
// After adding or removing a product in DNN Commerce / NB_Store:
window.dispatchEvent(new CustomEvent('neuroon:cart-update', {
detail: {
items: [
{ productId: '@Model.Sku', name: '@Model.Name', price: @Model.Price, quantity: @Model.Quantity }
],
total: @Model.Total,
currency: 'EUR'
}
}));
If your module fires jQuery events, hook the dispatch to the equivalent event and apply a 200 ms debounce to avoid floods.
Step 8. Conversion tracking
On order confirmation, call the conversions endpoint server-side (do not rely on JS alone because of adblockers):
public void OnOrderConfirmed(Order order)
{
var client = new NeuroonClient();
client.TrackConversionAsync(
orderId: order.Id.ToString(),
orderValue: order.Total,
currency: order.Currency,
lines: order.Lines.Select(l => new NeuroonClient.ConversionLine
{
ProductId = l.Sku,
SearchLogId = l.SearchLogId, // required — read from the widget cookie
Quantity = l.Quantity,
LineTotal = l.Price * l.Quantity
})
).GetAwaiter().GetResult();
}
SearchLogIdis@NotBlankonConversionItem(ShopRequestDTO.java:92). The widget writes it as a cookie on the buyer's browser on every result click. Your DNN front end must forward it to the backend with each order line (hidden field on the checkout form, or an AJAX call before confirmation). Without it the API responds 400.
Step 9. Verify the integration
# 1) Check that the sync went through
curl -H "X-Shop-API-Key: $NEUROON_API_KEY" \
"https://dev-api.neuroon.ai/api/plugin/shops/$NEUROON_SHOP_ID/products?size=5"
# 2) Run a test search with the widget token
curl -H "X-Widget-Token: $TOKEN" \
"https://dev-api.neuroon.ai/api/widget/search?q=t-shirt&limit=3"
# 3) Inspect the shop info and quota
curl -H "X-Shop-API-Key: $NEUROON_API_KEY" \
"https://dev-api.neuroon.ai/api/plugin/shops/me"
Or open the API playground directly and try with your own API key (Production/Development toggle in the header).
Common DNN errors
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized on /products/sync | Empty or invalid API key | Check HostController.GetEncryptedString and the decryption key in web.config. |
403 Forbidden only in browser | Origin header mismatch | Server-to-server from IScheduledTask does not send Origin (correct). If your request comes through a client-side proxy, add the domain to the shop. |
429 Too Many Requests | Plan rate limit exceeded | Implement backoff (Polly already does it) and split large syncs into batches of 500 with pauses. |
name: Size must be between | name exceeds 512 characters | Truncate or normalize titles. |
| Widget does not load, CSP error | Skin CSP policy blocks the CDN | Add cdn.neuroon.ai and dev-api.neuroon.ai (or api.neuroon.ai) to script-src and connect-src. |
| Expired token | Local widget token cache expired | The cache from step 5 already handles this; review the WidgetTokenProvider helper. |
Next steps
- Authentication · Widget token — full flow and rotation.
- API · products-sync — exhaustive endpoint reference with playground.
- Recipe · Server-to-server token — generate tokens from other stacks.
- Reference · Errors — error codes catalog.