Notifio sells two things. The lifetime licence is bought on the website at notifio.app/pricing, which is an ordinary web checkout and not interesting. The auto-reply upgrade is bought from inside the desktop app, and that one has a shape worth writing down.
It has to be bought from inside the app because of what it attaches to:
/**
* Starts a Stripe Checkout session for the one-time auto-reply upgrade.
*
* Requires an existing, active licence: the upgrade extends the lifetime
* licence rather than replacing it, so we authenticate with the same
* email + token pair the desktop app already holds. That is also why this is
* driven from inside the app rather than from the public pricing page.
*/
The app is the thing holding the credential, so the app is where the purchase starts. The question is where it finishes.
The implementation I did not write
Electron makes the wrong version a one-liner:
const checkout = new BrowserWindow({ width: 500, height: 700 });
checkout.loadURL(session.url);
That works. It is also a window with no address bar, no padlock, no certificate information and no way for the person looking at it to establish that it is Stripe rather than a page I drew. The app has just asked them for a card number inside a frame whose entire chrome is under my control. Everything about that is the shape of a phishing page, and the only thing distinguishing it is that I happen to be honest.
The practical problems stack up behind the trust problem. A password manager that fills card details does it in the browser the user installed it in, not in my BrowserWindow. A 3D Secure step redirects to a bank domain, so an in-app checkout means navigating a window I control to a bank login, which is the exact thing people are told never to do. And if the payment goes wrong, "it failed in the app" is a support conversation with no URL in it.
So nothing about payment happens inside the app. The app's job is to obtain a URL and get rid of it.
The renderer has exactly one way to reach Electron
async function handleUpgrade() {
setBusy(true);
const { ok, data } = await upgradeAutoReply();
setBusy(false);
if (!ok || !data.url) {
showToast(data.error || "Could not start checkout");
return;
}
// Non-local URLs go to the system browser, so the user sees Stripe's own
// address bar rather than a window we control.
window.open(data.url, "_blank");
showToast("Opened Stripe checkout in your browser");
}
window.open looks like a lazy choice there and is actually the only one available. The renderer in this app has no preload script and no IPC bridge: it is a React bundle served over HTTP by an Express server running in the main process, and it talks to the main process the way any web page talks to a server, which I wrote about in Our Electron renderer has no preload script, and it talks to 28 HTTP endpoints on 127.0.0.1. There is no ipcRenderer, no window.electron, nothing injected. window.open is a plain browser API, and it is the one browser API whose behaviour Electron lets the main process intercept.
Here is the interception:
const internalOrigin = `http://localhost:${serverPort}`;
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(internalOrigin)) {
shell.openExternal(url);
}
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(internalOrigin)) {
event.preventDefault();
shell.openExternal(url);
}
});
The return { action: 'deny' } is outside the if, and that placement is the whole policy. Every window.open from the renderer is denied a window, unconditionally. The only question the handler asks is whether the URL is worth handing to the operating system first. The app therefore cannot open a second Electron window at all, and no amount of HTML in the renderer can produce one.
will-navigate applies the same rule to the window the app already has, so a link in the UI cannot take the app's own frame somewhere else. The frame only ever shows http://localhost:3000, which is our own renderer, and everything else is the user's browser's problem by design.
The pleasant consequence is that handleUpgrade contains no Electron-specific code. It calls window.open like any web page would, and the policy that turns that into shell.openExternal lives in one place in the main process where it covers every URL in the app rather than every call site.
Four hops and no card details anywhere near us
renderer window.open(url)
| POST /api/autoreply/upgrade (127.0.0.1, no auth needed, it is local)
local server reads licence from config.json
| POST /api/checkout/upgrade (notifio.app, email + token)
website resolves licence, creates Stripe session
|
Stripe returns session.url
|
system browser the only process that sees a card number
The renderer never holds the licence token. It posts to a local endpoint, and the local server reads the credential off disk itself:
app.post('/api/autoreply/upgrade', async (req: Request, res: Response) => {
const cfg = loadConfig();
if (!cfg.license?.email || !cfg.license?.token) {
return res.status(403).json({ error: 'Activate Notifio before upgrading.' });
}
try {
const url = await entitlement.createUpgradeUrl(cfg.license.email, cfg.license.token);
res.json({ url });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
});
This is not a security boundary, and it would be dishonest to present it as one: the config file is on the user's disk and they can read it whenever they like, which is the subject of A local app cannot keep a secret from its owner, so stop pretending. It is a layering boundary. The renderer deals in intents, the local server deals in credentials, and the website is the only thing that decides whether a licence may buy anything.
The website says no in three distinct ways, and each one is a sentence the user needs to read differently:
// no licence for this email and token
return NextResponse.json({ error: "No active licence found for this email and token." }, { status: 403 });
// bought already, and the upgrade is permanent
return NextResponse.json({ error: "Auto-reply is already unlocked on this licence." }, { status: 409 });
// per-IP limit on checkout creation
return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
All three arrive at showToast(data.error || "Could not start checkout"). The fallback string exists for the case where the website is unreachable and there is no message to show, and the reason the server's own message is preferred over anything the app invents is that only the server knows which of the three happened. An app that renders its own guess at the reason is an app that tells someone with an expired licence that they already own the upgrade.
The way back is a sentence, on purpose
Stripe takes the payment in the browser and lands on our success page:
success_url: `${appUrl}/success?upgrade=1&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing`,
At which point the money has moved, the webhook has flipped a column, and the app sitting behind the browser window knows none of it. What that page says is:
"Go back to Notifio and open the Auto-reply tab",
"Press Refresh, and it should now say Unlocked",
Three lines of instruction instead of a deep link. The alternative is registering a custom URL scheme, notifio://upgraded, and having the success page redirect into it. I decided against it twice over. Registering a scheme is installer-level configuration on both platforms and another thing that can be wrong in a packaged build, and more importantly a registered scheme is a door any web page in any browser can knock on. This app runs a local HTTP server with a few dozen endpoints behind it. Adding a second, OS-level entry point to that so the user can avoid clicking Refresh is a bad trade in a way that is obvious as soon as you write both sides down.
Refresh forces the check rather than using the cached answer:
export async function getEntitlement(
email: string,
token: string,
options: { force?: boolean } = {}
): Promise<ValidateResult & { stale: boolean }> {
const cached = readCache();
const age = cached ? Date.now() - new Date(cached.fetchedAt).getTime() : Infinity;
if (!options.force && cached && age < REFRESH_AFTER_MS) {
return { ...cached, stale: false };
}
const fresh = await fetchFromServer(email, token);
if (fresh) return { ...fresh, stale: false };
// Server unreachable, and a purchase already made does not lapse because we
// could not phone home.
if (cached) return { ...cached, stale: true };
return { valid: false, autoReply: false, stale: true };
}
Without the button, the upgrade would appear on its own within six hours, which is the refresh interval. The button turns "within six hours" into "now", and the thing it is really protecting against is the state where someone has paid and the app still says Not unlocked. That state is survivable for a second and corrosive for an afternoon.
Two details in there matter more than they look. A cached yes is returned even when the server cannot be reached, because a one-time purchase does not lapse when a laptop goes offline, and the stale flag lets the UI say so rather than lying either way. And anything that is not an explicit refusal from the server is treated as "we do not know" rather than as a no, which is a mistake I had already made once and wrote up in A 429 is not a no: the three answers our licence check has to tell apart.
The card disappears for good
// Nothing below does anything without the upgrade, so the sales card is the
// whole page until someone buys it. Once they have, the card is gone for good:
// a one-time purchase that never renews has nothing left to report.
if (!upgraded) {
And before that:
// Until the first fetch lands we do not know which of the two pages to draw,
// and guessing would flash the upgrade card at people who already paid.
if (!loaded) {
Those two comments are about the same thing from opposite ends. A one-time purchase has no renewal, no expiry and no billing state, so after it completes there is nothing to show the customer, and the correct UI for it is its own absence. Which makes the loading state unusually important, because the default render of that panel is the sales pitch, and showing a paying customer a sales pitch for a second every time they open a tab is the kind of small insult that software does constantly.
The upgrade itself is £20, one-time, on top of a £20 lifetime licence, and both numbers come from one file of integer pence that the website and the app both read. The webhook that tells the two purchases apart is a separate story, in Two products, one Stripe event, and the branch that must come first.
If you want to see the end of this chain, the prices and what each one includes are at notifio.app/pricing, the installers are at notifio.app/download, and anything that goes wrong with a purchase arrives through the form at notifio.app/help.
Top comments (0)