Notifio sells two things, both one-time: a £20 lifetime licence for monitoring rental search pages, and a £20 upgrade that lets the app fill in the contact form for you. Both are Stripe Checkout Sessions in mode: "payment". Both are created by an API route that is about 90 lines.
One of them is reachable by anybody who loads notifio.app/pricing. The other cannot be reached from the website at all.
/**
* 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.
*/
An upgrade is a purchase made by a specific existing customer, and the credential identifying that customer lives in a file on their machine. There is no login on the website, no session cookie, nothing to attach the purchase to. Rather than invent an account system for a second £20 payment, the app sends what it already has.
What follows is the ladder of refusals both routes run through before any money machinery is touched, and the single rule behind the ordering.
Rung one: rate limit, before the body is even read
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const { success } = await checkoutRatelimit.limit(`upgrade:${ip}`);
if (!success) {
return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
}
The limiter is shared, the key is not:
// Stricter limit for checkout — 5 per minute per IP
export const checkoutRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
analytics: false,
prefix: "notifio:checkout",
});
The public route keys the limit on the raw IP. The upgrade route keys it on the same IP with an upgrade: prefix in front. Same instance, same window, two separate buckets, because a string prefix is the entire difference between "these two endpoints share a budget" and "these two endpoints each get one". Worth being deliberate about rather than discovering: somebody buying the upgrade from inside the app should not be throttled because the same office IP was poking the pricing page, and the two flows have genuinely different legitimate rates.
Five per minute is low on purpose. A human buys once. The number exists for the case where something retries in a loop, and the cost of being wrong is a message telling a real person to try again in a moment.
Note the order. This happens before req.json(). A rate limiter that runs after parsing the body is a rate limiter that still parses every body.
Rung two: the body, with no schema validator
let email: string, token: string;
try {
const body = await req.json();
email = (body.email ?? "").trim().toLowerCase();
token = (body.token ?? "").trim().toUpperCase();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
No Zod, no schema. ?? "" on both fields and a try around the parse, which covers the only two ways this can fail: the body is not JSON, or the fields are missing. Everything else is handled by the next rung, which looks the values up and does not care what shape they arrived in.
The normalisation is the part that matters. toLowerCase on the email because that is how it is stored, toUpperCase on the token because the activation token's alphabet is uppercase with the ambiguous characters removed. A customer who types their token in lowercase, or whose email client helpfully capitalised the first letter of their address, gets a licence lookup that works. Normalising at the boundary means every rung below it compares like with like.
The public route has one extra check here, because it has no token to look up:
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "Valid email is required." }, { status: 400 });
}
A deliberately loose regex. It is not trying to decide whether an address is deliverable, only whether it is worth sending to Stripe, because the real validation is that the activation token goes to that address and nowhere else. An address that passes this and does not exist costs us a bounce and a support email.
Rung three: the licence, with one query and no hints
const license = await resolveLicense(email, token);
if (!license) {
return NextResponse.json(
{ error: "No active licence found for this email and token." },
{ status: 403 }
);
}
resolveLicense is five lines of query and three conditions:
if (!license || license.email !== email || !license.active) return null;
Token lookup first, because it is the unique indexed column, then the email has to match, then the licence has to be active. All three failures return the same null, which the comment on the function is explicit about:
/**
* Look up a licence by token and verify the email matches.
* Returns null for any mismatch so callers cannot leak which part was wrong.
*/
So the 403 message says "this pair does not work" and never "that token exists but the email is wrong". The client-facing cost is a slightly less helpful error. The benefit is that the endpoint cannot be used to test whether a token is real, which matters more for a 12-character token than it would for a password, because a token is the only credential the app has.
403 rather than 401 is deliberate too: there is no authentication scheme to challenge and no WWW-Authenticate header that would mean anything here. The credentials were supplied, they were understood, and the answer is no.
Rung four: the thing you cannot buy twice
// 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 }
);
}
The public route has the mirror image of this:
// Block duplicate purchases — one license per email
if (existing.length > 0) {
return NextResponse.json(
{ error: "A license already exists for this email address." },
{ status: 409 }
);
}
Two 409s saying the same thing from opposite directions: the base route refuses because a row exists, the upgrade route refuses because a column on that row is already true. Both are one indexed read.
This rung is a courtesy rather than a safety net. The actual protection against a double charge is in the database, where email and auto_reply_session_id are unique columns, which I wrote about in the one-table schema post. A 409 here means the customer never reaches a payment page for something they own. If they somehow do, the webhook still cannot apply it twice. A guard in the route is for the user's experience, a constraint in the database is for the money. Conflating those is how people end up with one of the two.
Then, and only then, Stripe
const country = countryFromRequest(req);
const mp = managedPaymentsCheckout(country);
const session = await createCheckoutSession({
mode: "payment",
customer_email: email,
...mp.session,
line_items: [{ price_data: { currency: PRICE_CURRENCY, product_data: { name: UPGRADE_PRODUCT_NAME, ...mp.productData }, unit_amount: UPGRADE_PRICE_PENCE, ...mp.priceData }, quantity: 1 }],
metadata: { kind: UPGRADE_METADATA_KIND, licenseId: license.id, email },
success_url: `${appUrl}/success?upgrade=1&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing`,
}, { managedPayments: mp.enabled, country });
Four things to point out in that object.
unit_amount is GBP pence from a constants file with no Stripe SDK behind it, which is what lets a marketing page import the price without pulling a server-only dependency into its module graph. Everything about showing that number in a visitor's own currency happens on the website and never here.
metadata.kind is how one webhook tells the two products apart, since both arrive as the same checkout.session.completed event and the branch order matters.
success_url carries ?upgrade=1, which exists purely so the confirmation page can say "auto-reply unlocked, nothing else to pay" instead of "your activation token is on its way", because somebody buying the upgrade is already activated and no token is coming. It also carries {CHECKOUT_SESSION_ID}, which no code of ours ever reads.
cancel_url points at notifio.app/pricing even for the in-app upgrade, because a public page about both products is a better landing spot for a cancelled payment than a dead end.
The ordering rule
Read the ladder as costs rather than as validations:
- A Redis counter, one round trip, no body read.
- A JSON parse and two string operations, no I/O.
- One indexed Postgres read.
- One boolean on the row already fetched.
- A network call to Stripe that creates a real object in a payment system.
Each rung is cheaper than the one below it and rejects a superset of what the rungs below would reject. Sort your guards so the cheapest one that can say no runs first, and put anything that creates state last. The practical payoff is that a hostile loop against either endpoint costs five Redis increments a minute and nothing else, and that a Stripe object is only ever created on behalf of a request that has already been fully understood.
The desktop app that calls this is at notifio.app/download, and the answers about licences and payments are on notifio.app/help.
Top comments (0)