Integrating Microsoft Store Subscription and Permanent Licenses in Electron Desktop Apps
When your Electron app needs to sell subscriptions and perpetual licenses through Microsoft Store, how do you cleanly integrate that WinRT commercial API into your business logic? This is an old story—we stumbled and sweated through it in HagiCode Desktop, eventually figuring out this layered approach. Writing it down as a roadmap for those who come after.
Background
HagiCode Desktop is an Electron application distributed through Microsoft Store. Commercially, there are only two product types: one is the Sponsor Plan (sponsor subscription, Store ID 9N0BTGWV23M1), renewing monthly or annually—like a relationship that needs constant watering. The other is TurboEngine (perpetual license DLC, Store ID 9NSD809W18Z6), a one-time purchase—like that old book on the shelf you never opened again, but it's yours nonetheless.
The problem is, the Electron runtime itself doesn't have the ability to directly call Microsoft Store commercial APIs. Store purchases and license queries all rely on WinRT's Windows.Services.Store namespace, and these APIs can only be used in native code. But the Electron main process is in a Node.js environment—you can't import a WinRT type there—it's like trying to hold moonlight in your hand, but coming up empty.
Even more troublesome is that commercialization status isn't something you can just check once and feel at peace. Users might unsubscribe, renew, or switch devices in the Store client, and the app's feature toggles need to change accordingly. Making users click "refresh" every time makes for a poor experience, but querying too frequently hits Store rate limits—network jitters and a perfectly good subscription gets reported as "not subscribed," locking out paid users' features. Building something like that makes you want to laugh to hide the tears.
There's another easily overlooked corner: different distribution channels behave differently. Non-Store versions (like portable builds) don't have the Store runtime at all—calling StoreContext will fail outright. In such cases, the app shouldn't crash, nor should it pretend users have subscriptions. You need to give a clear "unsupported" status. After all, pretending to have something is sadder than honestly admitting you don't.
For these reasons, we built a layered architecture. This approach later crystallized into two HagiCode OpenSpec proposals: desktop-subscription-entitlements (subscription license persistence, standardization, and entitlement derivation) and desktop-turboengine-msstore-license (TurboEngine perpetual license purchase, refresh, and DLC injection). More on these below.
About HagiCode
The solution shared in this article comes from our practice in the HagiCode project. HagiCode is an AI coding assistant project spanning multiple platforms: Web, Desktop, CLI, and more. HagiCode Desktop is the desktop product line discussed in this article, and the complete source code is available at HagiCode-org/site.
Layering is Key
Writing Store calls directly in the Electron main process gets messy. WinRT async objects, COM threading models, window handle passing—mixing these with business logic makes the code nearly unmaintainable. Our approach is to slice the entire pipeline into four layers, each shouldering one responsibility:
Renderer Process (React)
↕ IPC bridge
Electron Main Process (TypeScript)
↕ broker interface
Native Node Addon (C++)
↕ WinRT
Windows.Services.Store
At the bottom is a C++ native addon named hagicode_store_purchase_addon.node. It exposes only two methods: requestPurchase(storeId, windowHandle) and queryStoreStatus(storeId, productName, productKinds). These correspond to WinRT's RequestPurchaseAsync and GetAssociatedStoreProductsAsync / GetUserCollectionAsync. The addon's entire job is to convert WinRT async results to JSON and send them back to the JavaScript thread via Napi::ThreadSafeFunction.
In the middle is a TypeScript StoreLicenseService. It doesn't care about WinRT, only about business semantics: refresh, retry, cache, entitlement derivation, and status broadcasting. It communicates with the underlying layer through a StoreLicensePlatformBroker interface with just three methods: queryStatus(), purchase(), and dispose().
At the top are SubscriptionService and TurboEngineLicenseService, which are essentially thin wrappers around StoreLicenseService, each bound to specific product configurations (Store ID, product name, entitlement names).
This layering brings a direct benefit: subscriptions and perpetual licenses can share the same engine. StoreLicenseService is a generic class parameterized by snapshot type and entitlement name. Adding a new product only requires writing another StoreLicenseProductConfig—no need to copy-paste the entire service. If HagiCode later needs to integrate macOS's StoreKit or other commercialization channels, theoretically only the broker implementation needs to change—no business layer code needs to move. This is the gentle power of layering.
Standardization: Cleaning Store's Dirty Data
The data returned by WinRT is quite "raw." StoreProductQueryResult contains nested IVectorView and IMap, SKU's CollectionData.EndDate is in Windows DateTime ticks (starting from 1601, in 100-nanosecond units), and error codes are HRESULTs. If you throw these directly at the renderer process, the frontend code will likely collapse.
So the broker layer does standardization, flattening the raw WinRT objects into RawStoreLicenseState:
export interface RawStoreLicenseState {
fetchedAt: string;
availability: 'supported' | 'store-unavailable' | 'error';
appLicenseActive: boolean;
product: RawStoreLicenseProduct | null;
sku: RawStoreLicenseSku | null;
license: RawStoreLicense | null;
purchaseEligibility: 'licensable' | 'not-licensable' | 'license-action-not-applicable' | 'network-error' | 'server-error' | 'unknown';
errorCode: string | null;
errorMessage: string | null;
}
There's a detail worth mentioning: the query actually uses two Store calls. One is GetAssociatedStoreProductsAsync (products associated with the current app), the other is GetUserCollectionAsync (products the user already owns). The reason is simple: subscription products might appear in the association list but the user hasn't bought them yet, or they might already be in the user's collection. Only by cross-referencing the two results can you accurately determine "whether owned"—it's like looking at a person from two angles to avoid misjudgment.
The ticks-to-ISO date conversion is worth noting:
const WINDOWS_EPOCH_OFFSET_MILLISECONDS = 11644473600000n;
const HUNDRED_NANOSECONDS_PER_MILLISECOND = 10000n;
// ticks are in 100-nanosecond units from 1601; convert to milliseconds, then subtract the Windows/Unix epoch difference
const unixMilliseconds =
ticks / HUNDRED_NANOSECONDS_PER_MILLISECOND - WINDOWS_EPOCH_OFFSET_MILLISECONDS;
11644473600000 is the number of milliseconds between 1601-01-01 and 1970-01-01. This conversion is also done in the C++ addon (using FileTimeToSystemTime), and both sides' results must match—otherwise you get bizarre misalignments like "main process sees today, addon sees yesterday." When time (like relationships) is misaligned, nothing makes sense.
State Machine: From "Raw Data" to "Business State"
After standardization, we need another layer of abstraction. Business code doesn't actually need to know what purchaseEligibility is—it only cares "is the subscription actually valid." The deriveStatus function in normalize.ts does this translation:
function deriveStatus(
raw: RawStoreLicenseState,
productConfig: StoreLicenseProductConfig
): StoreLicenseStatus {
if (raw.availability !== 'supported') {
return 'unknown';
}
const expirationDate =
raw.license?.expirationDate ?? raw.sku?.collectionEndDate ?? null;
const expirationTime = expirationDate ? Date.parse(expirationDate) : Number.NaN;
const hasExpired = Number.isFinite(expirationTime) && expirationTime < Date.now();
const isOwned = Boolean(
raw.license?.isActive ||
raw.sku?.isInUserCollection ||
raw.product?.isInUserCollection
);
if (isOwned && !hasExpired) {
return 'active';
}
if (hasExpired) {
return 'expired';
}
// ...other branches: inactive / canceled / grace-period / pending
}
The final business states are seven: active, inactive, expired, canceled, grace-period, pending, unknown. The renderer process only looks at this one field, never touching the raw data again.
There's a design trade-off here: the active determination doesn't check whether expirationDate exists. The reason is simple—perpetual licenses (TurboEngine) don't have expiration dates at all; Store returning license.isActive as true is sufficient. If we insist "must have expiration date to be active," we'd incorrectly judge buyout users as unsubscribed—which would be too hurtful. This detail is explicitly stated in the spec: perpetual licenses remain active when no expiration metadata exists.
Fault Tolerance: Don't Lose Subscriptions When Network is Poor
Store APIs return errors or time out during network jitter. If we clear the state on every failure, paid users' permissions will frequently drop—this is obvious, but it does happen. HagiCode's strategy is "preserve last known good state on failure, mark as stale."
StoreLicenseService.refresh has an internal retry loop (default 3 times, 350ms interval) and does "status regression" detection: if the previous state was active but this query returns something not active, treat it as a temporary error and retry rather than accepting this degraded result directly.
private getRetryReason(
snapshot: TSnapshot,
recoverySnapshot: TSnapshot | null
): 'store-unavailable' | 'status-regression' | null {
if (snapshot.availability !== 'supported') {
return 'store-unavailable';
}
if (recoverySnapshot?.status === 'active' && snapshot.status !== 'active') {
return 'status-regression';
}
return null;
}
Only after all retries fail will it use createStaleSnapshot to return the last good state marked as stale, accompanied by a store-refresh-failed diagnostic. The renderer process can decide whether to disable features in stale state—typically, continue allowing access, giving users a buffer. After all, no one wants to be unable to use something they paid for just because the network was bad that day.
Another detail is refreshInFlight deduplication. If a refresh is already in progress, new refresh calls reuse the same Promise, avoiding concurrent requests overwhelming the Store—the principle is the same as queuing:挤成一团反而谁也过不去。
Entitlement Derivation: Decoupling Status and Feature Toggles
Subscription status answers "is the subscription valid," but feature toggles care about "can the user use a specific feature." These aren't one-to-one correspondences. An active subscription might correspond to multiple entitlements (sponsor badge, premium feature gates), and future plans might include tier-based differentiation.
So we added an EntitlementEvaluator layer in between:
evaluate(snapshot: TSnapshot): TEntitlement[] {
if (snapshot.availability !== 'supported' || snapshot.status !== 'active') {
return [];
}
return [...this.activeEntitlements];
}
In the subscription product configuration, we declare which entitlements it grants when activated:
export const subscriptionEntitlementNames = [
'sponsorBadge',
'premiumFeatureGate',
] as const;
This way, feature code depends only on the entitlements array, not directly reading status. Future tier additions or entitlement splits only require changing configuration and the evaluator—consumer code doesn't need to change. This decoupling is especially important in multi-product-line projects like HagiCode—subscriptions and perpetual licenses share the same entitlement model, and the frontend only needs to query one array. The world suddenly feels much cleaner.
Runtime Degradation: What If No Store?
Calling the addon from non-Store distributed versions (portable builds, development environments) will fail. HagiCode uses lazy initialization and degradation in MicrosoftStoreSubscriptionBroker:
private async initializeBroker(): Promise<StoreLicensePlatformBroker> {
try {
return this.setBroker(
await this.adapterFactory(this.windowHandle, this.productConfig)
);
} catch (error) {
// If Store runtime isn't found, degrade to a "supports nothing" broker
return this.setBroker(new UnavailableSubscriptionPlatformBroker(error));
}
}
UnavailableSubscriptionPlatformBroker implements the same interface, but its queryStatus always returns store-unavailable, and purchase always returns not-supported. Upper-layer code is completely unaware—it just becomes "unsupported" status, and the renderer shows a "Please get through Microsoft Store" prompt based on that.
This design allows the entire commercialization module to run safely under any distribution channel without crashing due to missing Store runtime. If you're also building a multi-channel Electron app, this is particularly worth copying—don't let "environment not supported" become a crash. Some things, when admitted honestly, are actually more dignified.
Startup Flow and IPC Channels
On app startup, main.ts decides whether to initialize the subscription service based on the --desktop-subscription-enabled=1 parameter. This parameter is only included in the Store version's startup command, avoiding wasted loading in non-Store versions—every bit of effort saved is worth it.
function initializeSubscriptionService(): void {
if (!subscriptionFeatureEnabled || subscriptionService) {
return;
}
subscriptionService = new SubscriptionService({
broker: new MicrosoftStoreSubscriptionBroker({
windowHandle: mainWindow?.getNativeWindowHandle() ?? null,
}),
entitlementEvaluator: new EntitlementEvaluator(),
});
registerSubscriptionHandlers({
subscriptionService,
getWindows: () => ElectronBrowserWindow.getAllWindows(),
});
}
windowHandle comes from mainWindow.getNativeWindowHandle(). This Buffer is parsed into a bigint and passed to the native addon, which then uses it to call IInitializeWithWindow::Initialize. This is a necessary step for Store API to pop up a purchase dialog in desktop apps (non-UWP); otherwise, the purchase window has no owner and behaves abnormally—a window without belonging is like a person without belonging: always drifting.
The renderer process calls the main process through the bridge exposed by preload:
const subscriptionBridge: SubscriptionBridge = {
getSnapshot: (options) => ipcRenderer.invoke(subscriptionChannels.getSnapshot, options),
verifyStartup: () => ipcRenderer.invoke(subscriptionChannels.verifyStartup),
refresh: () => ipcRenderer.invoke(subscriptionChannels.refresh),
purchase: () => ipcRenderer.invoke(subscriptionChannels.purchase),
onDidChange: (callback) => {
const listener = (_event, snapshot) => callback(snapshot);
ipcRenderer.on(subscriptionChannels.changed, listener);
return () => ipcRenderer.removeListener(subscriptionChannels.changed, listener);
},
};
State changes are pushed to all windows via broadcastSnapshotChanged. After purchase completion, completePurchase triggers a refresh('purchase'), and the new state is automatically broadcast, updating the subscription UI in the renderer process in real time.
Additionally, main.ts has a setInterval silently syncing in the background (subscriptionService?.refresh('scheduled')). This allows the running app to catch renewals and unsubscribes users quietly make in the Store client. The frequency naturally can't be too high (Store has rate limits); the code uses minute-level intervals—not too far, not too close, just right.
Some Easy-to-Fall Pits
First, native addon thread safety. After WinRT async operations complete, the callback isn't on the JavaScript thread. Calling Napi APIs directly in the callback will crash. The addon uses Napi::ThreadSafeFunction::BlockingCall to deliver results back to the JS thread:
auto const status = threadsafeFunction_.BlockingCall(
payload,
[self](Napi::Env env, Napi::Function, PurchaseCompletion* data) {
std::unique_ptr<PurchaseCompletion> ownedData{ data };
self->ResolveOnJs(env, *ownedData);
});
BlockingCall blocks the WinRT callback thread until the JS thread finishes processing. In this mode, the callback thread cannot be the JS thread itself, otherwise you get deadlock. Fortunately, WinRT's Completed callbacks typically run on STA or thread pools, which satisfies this condition.
Second, COM initialization. The Electron main thread might have already initialized COM. The addon wraps winrt::init_apartment in try-catch, ignoring failures:
try {
winrt::init_apartment(winrt::apartment_type::single_threaded);
} catch (...) {
// Electron might have already initialized COM for this thread; ignore
}
Without handling this, repeated initialization throws exceptions and addon loading fails. Some errors, when ignored, are actually correct.
Third, window handle precision. getNativeWindowHandle() returns a Buffer, which might be 4 bytes (32-bit) or 8 bytes (64-bit). It's then formatted as a hex string starting with 0x in the addon, and the C++ side parses it back to HWND using std::stoull. Why use strings instead of passing numbers directly? Because JavaScript's number precision is only 53 bits, and 64-bit pointers would lose precision. This pit is hard to discover without stepping in it—like some things, you can't explain them without experiencing them.
Fourth, state isolation. Subscription and perpetual license states must be stored separately. HagiCode's spec explicitly requires that TurboEngine's persistence not overwrite sponsor's state. The two snapshots are separated by different productKeys (subscription and turboengine), avoiding one product's refresh overwriting another's cache. When everyone minds their own business, the world is at peace.
Fifth, must refresh after purchase. After purchase completes, you must refresh once more to broadcast. completePurchase triggers refresh('purchase') for both succeeded and already-purchased cases, because Store's purchase result only tells you the transaction status, not the current license details. License status must be queried again—between promise and reality, there's always one confirmation.
Summary
This implementation has been running for a while and is relatively stable. What's most worth borrowing isn't any specific trick, but this layered approach: completely isolating "dealing with Store" in the broker and addon layers, with the upper layers handling only pure business semantics.
A few core experiences to remember:
- Touch WinRT only in the C++ addon; the addon only does "async to JSON"—no business semantics.
- Standardization and state machine are two layers; don't mix raw data and business state together.
- On network failure, preserve the last good state and mark it stale—don't take away paid users' permissions.
- Decouple entitlements and status; feature code only looks at the
entitlementsarray. - Non-Store environments use a degraded broker—never let "unsupported" become a crash.
If you're also doing Store commercialization for Electron apps, I hope this layered approach helps you avoid a few pitfalls.
The solution shared in this article is what we actually stumbled through and optimized while developing HagiCode. If you think it has some value, it means our engineering skills are decent—which makes HagiCode itself worth a second look...
References
- HagiCode Official Website
- HagiCode-org/site GitHub Repository
- Windows.Services.Store namespace - WinRT Documentation
- Electron getNativeWindowHandle Documentation
- Node-API ThreadSafeFunction Documentation
Summary
For "integrating Microsoft Store subscription and permanent licenses in Electron desktop apps," a more prudent approach is to first validate key configurations, dependency boundaries, and implementation paths, then fill in optimization details.
When goals, steps, and acceptance criteria are clear, such solutions usually proceed more smoothly into actual delivery.
Original Article & License
Thanks for reading. If this article helped, consider liking, bookmarking, or sharing it.
This article was created with AI assistance and reviewed by the author before publication.
- Author: newbe36524
- Original URL: https://docs.hagicode.com/go?platform=devto&target=%2Fblog%2F2026-06-16-electron-msstore-subscription-license%2F
- License: Unless otherwise stated, this article is licensed under CC BY-NC-SA. Please retain attribution when sharing.
Top comments (0)