Skip to main content

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:

  1. Products synced from your DB to Neuroon (nightly sync + delta).
  2. Search widget rendered in your theme, with token rotated server-side.
  3. 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) and System.Net.Http.Json (on .NET Framework you need System.Text.Json 6.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();
}

SearchLogId is @NotBlank on ConversionItem (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

SymptomCauseFix
401 Unauthorized on /products/syncEmpty or invalid API keyCheck HostController.GetEncryptedString and the decryption key in web.config.
403 Forbidden only in browserOrigin header mismatchServer-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 RequestsPlan rate limit exceededImplement backoff (Polly already does it) and split large syncs into batches of 500 with pauses.
name: Size must be betweenname exceeds 512 charactersTruncate or normalize titles.
Widget does not load, CSP errorSkin CSP policy blocks the CDNAdd cdn.neuroon.ai and dev-api.neuroon.ai (or api.neuroon.ai) to script-src and connect-src.
Expired tokenLocal widget token cache expiredThe cache from step 5 already handles this; review the WidgetTokenProvider helper.

Next steps