KennyG
Jul 6, 2026
visibility 220
star star star star star
(0 votes)

Ringing a Physical Sales Bell from Optimizely Commerce

This one started as a weekend project that got a little out of hand.

I built an “On Air” sign for my office — one of those LED signs streamers use — that turns itself on whenever I’m in a Teams call or a Zoom meeting, so my family knows not to walk in. It’s driven by a cheap TP-Link Kasa smart plug and a bit of PowerShell that watches for a call and flips the plug. Nothing fancy. But once you can turn a plug on and off from code, your brain starts looking for other things that should turn it on. And if you spend your days in Optimizely Commerce like we do, the answer shows up pretty fast: every time an order is placed, ring a bell.

Old shops had a bell on the door. Trading floors had an actual bell. There’s something motivating about a real, physical signal when money comes in — the whole team hears it. Modern commerce replaced that with a toast notification nobody looks at. So let’s put the bell back, using nothing but an order event and fifteen dollars of hardware.

Note: everything below is a standalone sample. It depends only on public Optimizely and Microsoft NuGet packages — nothing from our codebase — so you can drop the files straight into your own Commerce solution. I built, compiled, and ran it against EPiServer.Commerce.Core 14.45. The same order-event API carries forward to Commerce 15, so it should work there unchanged, but I've only tested on 14. — IOrderEvents.SavedOrder and OrderEventArgs.OrderGroup (an IPurchaseOrder) are the same on both.

Overview

There are three moving parts:

  1. Talk to the plug from C#.
  2. A “sales bell” that blinks the plug — on a background worker, so it never touches checkout.
  3. An order-event listener that rings the bell when an order is placed.

A quick word on part 1, because it’s the part that surprised me. These plugs used to speak a trivial local protocol over TCP, but newer firmware locked that down, and the usual libraries couldn’t authenticate against my account at all — the handshake just kept failing. The Kasa phone app works fine, though, because it goes through TP-Link’s cloud (wap.tplinkcloud.com). So that’s what we do too: log in, find the device by its alias, and relay the old set_relay_state command through the cloud’s passthrough method. The tradeoff is it needs internet and adds a second or two of latency — which is exactly why the bell runs in the background and never in the request.

Implementation

Talking to the plug

The plug client is a straight C# port of the PowerShell from the original project. It logs in once, caches the token and the device’s server URL, and re-logs in if a call fails. The one non-obvious bit: TP-Link wants the actual device command as a JSON string nested inside the request.

public async Task SetAsync(bool on, CancellationToken ct = default)
{
    if (!_opts.Enabled) return;

    // TP-Link expects the legacy device command as a JSON *string* inside requestData.
    var command = $"{{\"system\":{{\"set_relay_state\":{{\"state\":{(on ? 1 : 0)}}}}}}}";

    for (var attempt = 1; attempt <= 2; attempt++)
    {
        try
        {
            if (_token is null) await ConnectAsync(ct);

            var body = new
            {
                method = "passthrough",
                @params = new { deviceId = _deviceId, requestData = command }
            };

            var result = await PostAsync($"{_appServerUrl}?token={_token}", body, ct);
            if (result.GetProperty("error_code").GetInt32() != 0)
                throw new InvalidOperationException($"cloud error: {result.GetRawText()}");

            return;
        }
        catch (Exception ex)
        {
            _log.LogWarning(ex, "Kasa cloud attempt {Attempt} failed", attempt);
            _token = null; // force a fresh login next time
        }
    }

    _log.LogError("Kasa plug state not updated after retry");
}

ConnectAsync does the login and getDeviceList calls and stashes the deviceId and appServerUrl. It’s in the full sample; nothing exciting.

Heads-up if you drop this into an existing Commerce solution: many Optimizely sites transitively reference System.Net.Http.Formatting (via Microsoft.AspNet.WebApi.Client), which ships its own PostAsJsonAsync extension. When it’s in scope, the bare _http.PostAsJsonAsync(url, body, ct) becomes ambiguous and won’t compile. Fully-qualify the BCL one:

using var response = await System.Net.Http.Json.HttpClientJsonExtensions
    .PostAsJsonAsync(_http, url, body, ct);

A clean standalone project won’t hit this — it only bites when the WebApi.Client extension is also referenced.

Keeping it off the checkout path

Two rules for anything you bolt onto checkout: it can’t slow the order down, and it definitely can’t fail the order. A dead smart plug should never throw an exception up into SaveAsPurchaseOrder.

So the bell is completely decoupled. Ringing it just drops a token into a bounded channel — a fire-and-forget, non-blocking write — and a background service does the slow part (turn on, wait, turn off) somewhere else entirely.

public sealed class SalesBell : ISalesBell
{
    private readonly ChannelWriter<byte> _writer;
    public SalesBell(Channel<byte> channel) => _writer = channel.Writer;

    // Non-blocking, never throws. If the queue is full, we just drop the ring.
    public void Ring() => _writer.TryWrite(0);
}

public sealed class SalesBellWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var _ in _reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                using var scope = _scopes.CreateScope();
                var plug = scope.ServiceProvider.GetRequiredService<IKasaPlug>();

                await plug.SetAsync(true, stoppingToken);
                await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
                await plug.SetAsync(false, stoppingToken);
            }
            catch (Exception ex) { _log.LogWarning(ex, "Sales bell blink failed"); }
        }
    }
}

Because the channel is bounded and set to drop when full, a Black-Friday rush can’t queue up 4,000 blinks and fall an hour behind — the light just flickers and drops the overflow. Good enough for a novelty light.

Listening for orders

Optimizely’s order pipeline exposes IOrderEvents (in EPiServer.Commerce.Order). You resolve it from DI and subscribe to SavedOrder. Easy.

Gotcha: SavedOrder fires on every save of any order group. That includes carts, and it fires again every time an existing order’s status changes later. So we guard twice — only ring for an actual purchase order, and only the first time we’ve seen that order.

private void OnSavedOrder(object? sender, OrderEventArgs e)
{
    // Carts are order groups too — only ring for a completed purchase order.
    if (e.OrderGroup is not IPurchaseOrder po) return;

    // SavedOrder fires on every later save; only ring on first sight.
    var id = po.OrderLink.OrderGroupId;
    if (Seen.TryGetValue(id, out _)) return;
    Seen.Set(id, true, TimeSpan.FromHours(1));

    _log.LogInformation("Order {OrderNumber} placed — ringing the sales bell", po.OrderNumber);
    _bell.Ring();
}

Wiring it up

An IConfigurableModule registers everything and subscribes the listener once the site is up. The ModuleDependency on Commerce’s initialization module guarantees IOrderEvents is available by the time we ask for it.

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public sealed class SalesBellModule : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        var services = context.Services;

        services.AddOptions<KasaOptions>()
                .Configure<IConfiguration>((o, cfg) => cfg.GetSection("SalesBell").Bind(o));

        services.AddHttpClient<IKasaPlug, KasaCloudPlug>();

        services.AddSingleton(_ => Channel.CreateBounded<byte>(
            new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.DropWrite }));

        services.AddSingleton<ISalesBell, SalesBell>();
        services.AddHostedService<SalesBellWorker>();
        services.AddSingleton<SalesBellOrderListener>();
    }

    public void Initialize(InitializationEngine context)
        => context.Locate.Advanced.GetInstance<SalesBellOrderListener>().Subscribe();

    public void Uninitialize(InitializationEngine context)
        => context.Locate.Advanced.GetInstance<SalesBellOrderListener>().Unsubscribe();
}

One easy-to-miss detail: only ConfigureContainer gets an IServiceCollectionInitialize and Uninitialize receive an InitializationEngine, which has no .Services property — you resolve through context.Locate.Advanced.GetInstance<T>() instead.

Config lives in a SalesBell section — but put the password in Key Vault or DXP configuration, not appsettings.json in source control:

{
  "SalesBell": {
    "Username": "you@example.com",
    "Password": "keep-me-in-key-vault",
    "DeviceAlias": "On Air Lamp",
    "Enabled": true
  }
}

That "Enabled": false is worth setting in your Integration and Preproduction environments — otherwise a QA test order or a load test will strobe a light in a dark office at 2 a.m. Ask me how I know.

A few things to watch

A couple of honest caveats, because this is a novelty bolted onto a real checkout:

  • It’s an unofficial API. wap.tplinkcloud.com is reverse-engineered — the same path the Kasa app uses, but TP-Link could change it. Treat the bell as best-effort; the code already swallows every failure.
  • The success check is shallow — on purpose. SetAsync inspects error_code on the outer cloud envelope, which only tells you TP-Link accepted and relayed your passthrough request. The device’s own response is nested as a JSON string inside result.responseData, and we don’t crack it open — so “success” means the cloud took the command, not the relay physically flipped. Fine for a novelty bell; don’t reuse this client where you need to confirm device state.
  • You probably run more than one node. On DXP each instance gets the SavedOrder event, so each will try to ring. Either accept the extra cloud calls (harmless), gate ringing to one designated node, or move the bell behind a single small service. Your mileage may vary depending on your topology.
  • Throttle the strobe. The bounded channel keeps things from backing up, but during a real rush you may want one blink per N orders instead of one per order. Nobody needs a strobe light.
  • The de-dupe is per-instance. The in-memory cache is fine for a light. If you need it exact across a farm, back it with distributed cache or key off the order’s status transition.

Conclusion

The fun part is that once _bell.Ring() is a single line, the plug stops caring why it fired. Swap the bell for a color-changing bulb (green for a sale, red for a failed payment), point it at a different event — a first sale of the day, an order over some threshold, a low-stock SKU — and you’ve given your Optimizely site a little presence in the physical world.

The code above is the sample — it compiles and runs clean against Commerce 14 with nothing from any particular site, so you can drop the files straight in (the order-event API is unchanged in 15, though I’ve only run it on 14). The original Teams/Zoom “On Air” sign it grew out of — the PowerShell watcher and smart-plug script — is on GitHub: github.com/kennygutierrez/on-air-sign.

Thoughts, comments, concerns? Let me know below.

Jul 06, 2026

Comments

error Please login to comment.
Latest blogs
Optimizely’s Summer ’26 Roadmap: The CMS Is Starting to Look Less Like a Publishing Tool and More Like Marketing Infrastructure

Optimizely’s Summer ’26 Product Roadmap event was not just a list of product updates. At least, that is not the part I found most interesting. The...

Augusto Davalos | Jul 9, 2026

Optimizely Content JS SDK v2.1.0 — What's New and Why It Matters

  v2.1.0 of the Optimizely Content JS SDK and CLI landed on July 7, 2026. This is a substantial release bringing a wave of capabilities for...

Vipin Banka | Jul 8, 2026

Integrating a Third-Party DAM into Optimizely CMS 12: A Case Study

There is no handbook for wiring an external DAM into Optimizely CMS 12. This case study walks through the research, dead ends, and breakthroughs —...

WilliamP | Jul 7, 2026 |