Notifio sells two things. A lifetime licence for the monitoring and email alerts, and a one-time upgrade that unlocks auto-reply, where the app fills in and submits the listing's own contact form for you. Both are 20 pounds. Neither is a subscription.
That means our entire Stripe integration listens for exactly one event:
/**
* Both products are one-time payments, so `checkout.session.completed` is the
* only event we care about. There are no subscription or invoice lifecycles to
* follow: a licence is issued once, and the auto-reply upgrade is granted once.
*/
Two products arriving through one event is simpler than two products with two lifecycles, but it moves the whole problem into one place: telling them apart. Get that wrong and the failure is not a 500 in a log, it is a customer who paid for an upgrade and received a second licence with a different activation code.
The discriminator is metadata, set at session creation
// `kind` is how the webhook tells this apart from a new-licence purchase,
// both are one-time payments now. `licenseId` says which licence to upgrade.
metadata: { kind: UPGRADE_METADATA_KIND, licenseId: license.id, email },
UPGRADE_METADATA_KIND is a constant in the same file as the prices, imported by both the route that creates the session and the webhook that fulfils it, because a string literal typed twice is a string literal that will eventually be typed once.
You could reach for other discriminators. The product name is display text that changes when marketing changes. The amount is identical for both products today, which makes it useless and, worse, plausible. Metadata is the only field that exists specifically to carry your own meaning through Stripe and back.
Order is the bug
This is the part worth copying, and it is four lines:
// Auto-reply upgrade. Handled before the licence logic below, which assumes
// the payment is for a new licence and would otherwise issue a second one
// (and re-send an activation email) to someone who already has one.
if (session.metadata?.["kind"] === UPGRADE_METADATA_KIND) {
await handleUpgrade(session);
return NextResponse.json({ received: true });
}
The licence branch underneath it was written first, when there was only one product. It assumes any completed session is a purchase of a new licence, and it is good at that job: it looks for an existing row by session id, then by email, generates a token if there is none, and sends the activation email.
Every one of those steps is wrong for an upgrade. The customer already has a licence and a token they have already typed into the app. Reissuing gives them a second code for a product they already own and makes their support message "which of these two emails is my real licence?".
The upgrade branch does not merely come first, it returns. There is no shared tail where the two paths meet up again, because there is nothing after "which product was this" that both products want.
The grant has to survive being delivered twice
Stripe retries webhooks. Any fulfilment that is not idempotent is a bug with a delay on it:
/**
* Grant auto-reply on a licence, permanently.
*
* Idempotent: the session id is stored and the update is a no-op once the
* licence is already upgraded, so a Stripe webhook retry cannot double-apply
* (and, because `auto_reply_session_id` is unique, cannot be silently
* attributed to a second purchase either).
*/
Two protections, doing different jobs. Setting a boolean to true twice is harmless, which handles the ordinary retry. The unique constraint on the session id handles the case that actually worries me: two different sessions both claiming to have paid for the same upgrade. Rather than the second one quietly overwriting the first's provenance, the database refuses, and the row keeps pointing at the payment that really bought it.
The same instinct runs through the licence branch. The token is stable once created, so a retry after a failed activation email reuses it instead of issuing a second one, and an insert that loses a race falls back to reading the row that won:
} catch (err) {
// Concurrent webhook won the race and inserted first. Fall back to
// the row that won so we still deliver a valid token.
const [row] = await db
.select()
.from(licenses)
.where(eq(licenses.email, email))
.limit(1);
if (!row) throw err; // genuine DB error → 500 so Stripe retries
token = row.token;
}
Throwing on a genuine database error is not laziness. A 500 is how you ask Stripe to try again later, and "try again later" is exactly right when the answer is that our database was briefly unavailable.
Who is allowed to buy an upgrade
The upgrade is not on the public pricing page. It is sold from inside the app, and the route authenticates with the same email and token pair the app already holds:
/**
* 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.
*/
Which gives us a check that is easy to forget and obvious in hindsight:
// The upgrade is permanent, so there is never a reason to buy it twice.
if (license.autoReply) {
return NextResponse.json(
{ error: "Auto-reply is already unlocked on this licence." },
{ status: 409 }
);
}
A 409 before Checkout opens, rather than a refund conversation afterwards. For a permanent unlock, "you already own this" is a state you can detect for free, and letting the customer pay again is a choice, not an oversight.
The tax routing is deliberately shared with the base licence rather than reimplemented:
// Same merchant-of-record routing as the base licence: EU customers get VAT
// calculated and remitted by Stripe, everyone else is unchanged.
const country = countryFromRequest(req);
const mp = managedPaymentsCheckout(country);
That routing is its own story, written up in merchant of record for 27 countries, and deliberately off for our own. The point here is that a second product got it for free by calling the same two functions, which is the only reason a second product was a small change at all.
What the subscription cost us before we deleted it
Auto-reply used to be a subscription. The comment at the top of our licence module is the whole argument for the change:
/**
* Auto-reply used to be a subscription, which needed statuses, period ends and
* grace windows to answer "is this unlocked?". It is now a one-time purchase,
* so the answer is a single boolean column on the licence and there is no rule
* left to centralise beyond reading it.
*/
"Is this feature unlocked?" was a function of a status string, a period end, a cancellation timestamp and a grace policy, and every one of those needed a webhook to keep it current. Now the app asks one endpoint and reads one boolean:
return NextResponse.json({
valid: true,
plan: "lifetime",
autoReply: license.autoReply,
});
There is a real trade here and I do not want to pretend otherwise. One-time pricing means no recurring revenue, and a customer who pays 40 pounds once is worth less over five years than one paying monthly. What we bought with that is a product that cannot bill you for a month you did not use it, and a codebase where entitlement is a column rather than a state machine. For an app that a renter uses intensively for six weeks and then genuinely should stop paying for, that was not a close decision.
One more guard, because payment methods are slower than cards
// Only fulfil sessions that are actually paid. Async payment methods can
// fire checkout.session.completed while still "unpaid"/"processing".
if (session.payment_status && session.payment_status !== "paid") {
checkout.session.completed means the customer finished the flow, not that the money arrived. With cards those are the same moment, which is why this is easy to miss until you enable a bank-transfer style method and start handing out licences for payments that can still fail.
See it for yourself
Both prices, and what the upgrade actually does, are on notifio.app/pricing. The honest status of auto-reply differs per site, which is why each supported site's page says so individually, for example Kamernet and Pararius. How activation works after purchase is on the help page, and the app is at notifio.app/download.
Top comments (0)