ProjectsSubscription Tracker API
Subscription Tracker API
A production-style REST API for tracking recurring subscriptions and scheduling renewal reminders - JWT auth over atomic MongoDB transactions, an Arcjet layer for bot and rate-limit protection, and a durable Upstash workflow that sleeps for days at a time between reminder checkpoints.
A production-style backend built to be taken apart - every transaction, token and workflow step annotated with what it guarantees and where it stops.
A REST API for tracking recurring subscriptions - the sort of backend a subscription-management app sits on. A user signs up, adds their subscriptions with a price and a billing frequency, and the API works out each renewal date and lines up a run of reminders ahead of it.
I built it by following JavaScript Mastery's roughly five-hour production-backend video end to end. The aim wasn't a product - it was to slow down on each moving part (a transaction, a JWT, a real security middleware, a durable workflow engine) and write down, in the code itself, exactly what it does and what it leaves untouched. Most of the line count in the repo is comments.
Stack - Node.js, Express, MongoDB via Mongoose, jsonwebtoken + bcrypt, Arcjet, Upstash Workflow / QStash, dayjs. ESM throughout.
Every request runs the same gauntlet
Express runs middleware in the order it’s attached, so the order is a design decision, not an accident. This is the path a request takes before it reaches a controller.
- 01
Body & cookie parsers
express.json(), express.urlencoded() and cookie-parser go first, so req.body and req.cookies are already populated for everything downstream. Nothing after this has to re-parse the request.
- 02
Arcjet
Runs after the parsers so it can read the parsed request. A single protect() call carries three rules: a shield against injection-style attacks, a bot rule that allows the search-engine category and blocks the rest, and a token bucket - a 10-request burst per IP, then a refill of 5 every 10 seconds. A denied decision maps to 429 for the rate limit, 403 for a bot, 403 otherwise.
- 03
authorize (per write route)
Pulls the token out of the Authorization: Bearer header, runs jwt.verify against the secret, looks the user up by the id in the payload, and hangs that document off req.user. A tampered, expired or malformed token makes verify throw synchronously, so control jumps straight to the catch and the database is never touched.
- 04
Error handler
Signature (err, req, res, next), mounted dead last so it catches next(err) from every route above. It rewrites Mongoose failures into clean status codes - a bad ObjectId to 404, a duplicate key to 400, a validation error flattened into one 400 - and falls back to 500.
One ordering bug lives inside the router too: /upcoming-renewals has to be declared above /:id, or Express matches the literal path as an id and the wrong handler runs.
Auth, taken apart
A bcrypt-hashed password at sign-up, then a JWT signed with a server secret on every request after. bcrypt.genSalt(10) sets the work factor; sign-in re-hashes the attempt and compares. The interesting part was pinning down where the token’s job ends.
Sign-up is one transaction
const session = await mongoose.startSession(); session.startTransaction(); // ... create user, sign token ... await session.commitTransaction(); // catch -> session.abortTransaction()
Sign-up commits or it doesn't. Creating the user and signing its token happen inside one MongoDB session. If anything between them throws, the transaction aborts and a half-built account - a user row with no working token, say - never reaches the database. All or nothing, so a later request never trips over a partial record.
What the signature is responsible for
The payload can't be edited - swapping in someone else's userId - without the recomputed signature no longer matching. verify() catches it instantly.
A valid signature proves this server issued the token with its secret, and it wasn't forged by the client or a third party.
The payload is only Base64URL-encoded, not encrypted. Split the token at the dots and decode the middle part - or paste the whole thing into jwt.io - and the userId reads out in plain text, no secret needed. So the payload carries nothing but that id.
Signing says nothing about the wire. That is entirely on TLS.
The server keeps no record of what it signs. authorize only asks two things: was this signed with our secret, and is now before the exp claim. An old token passes both until it expires, and a fresh sign-in doesn't invalidate it. Undoing that needs a layer on top - a Redis blocklist, a tokenVersion stamp on the user that the payload has to match, or short access tokens with refresh.
A JWT is three Base64URL parts - header, payload, signature - joined by dots. jwt.io decodes the payload with no secret at all; it only shows “signature verified” once you paste the secret in. Base64 is a transport format, not encryption.
The stack the token sits in
In transitHTTPS / TLS
Encrypts the entire request and response - headers, URL, body, token. Without it the raw JWT can be lifted off public Wi-Fi. This is the layer doing confidentiality; the token itself does none.
Integrity & authenticitySigned JWT
Proves the payload arrived exactly as this server issued it, and wasn't forged. Stops a client editing its own authorization scope or identity.
At restbcrypt hash
The password is stored only as a salted bcrypt hash, so a database read can't recover it. If sensitive data ever had to live inside a token, that token would need encrypting too (JWE).
Why a second tab is already logged in
The cookie store is shared across every tab and window on a domain. The first tab receives an HttpOnly session cookie on sign-in; when a second tab opens and makes a request, the browser attaches that same cookie automatically, authorize verifies it, and the tab is in - no credentials re-entered. HttpOnly is what keeps page scripts from reading the token, so an XSS bug can’t exfiltrate it.
Notes left in the model layer
subscriptionSchema.pre('save', function () {
if (!this.renewalDate || this.isModified('startDate') || this.isModified('frequency')) {
this.renewalDate = addDays(this.startDate, PERIOD[this.frequency]);
}
if (this.status !== 'cancelled')
this.status = this.renewalDate < new Date() ? 'expired' : 'active';
});The schema keeps its own derived fields honest. renewalDate is computed from startDate plus the frequency (1 / 7 / 30 / 365 days), and status flips to expired once that date passes - unless the row was cancelled, which the hook leaves alone. Nothing else in the codebase has to remember to do this.
Object.assign(sub, req.body); // update: merge an unknown-shape payload sub.status = 'cancelled'; // cancel: one known field await sub.save(); // NOT findByIdAndUpdate
Updates go through the document. Query-level updates like findByIdAndUpdate bypass Mongoose hooks entirely, so a frequency change made that way would leave renewalDate and status stale. Fetching the document and calling save() is what re-runs the validators and the pre-save hook. Object.assign for a merge, direct assignment for a single known field.
Subscription.findById(id).populate('user', 'name email')The reminder knows who to reach. populate() is Mongoose's cross-collection lookup - its JOIN. Without it, sub.user is just the 24-character id. Naming only name and email projects those two fields and keeps a password hash from ever riding along.
const subscription = await Subscription.create({
...req.body,
user: req.user._id, // from authorize, never from the client body
});Ownership is set server-side. The owning user is stamped from the verified token, not read from the request. Every read and write on a subscription then re-checks req.user._id against the stored owner before doing anything - a 401 otherwise.
The reminder workflow
Reminders don’t run off a cron job polling the table. Each subscription gets its own Upstash workflow - a durable function that can sleep for days and survive a restart mid-run.
createSubscription saves the row, then fires an Upstash workflow with nothing but the new subscription's id in the body. It gets back a workflowRunId and returns immediately - the reminders are somebody else's problem now.
The workflow handler is an Upstash serve() function. QStash is the orchestrator: it starts the run, passes data into the first step, stores that step's result, then feeds it into the next - so state survives between steps and across restarts.
The subscription is fetched inside context.run('get subscription', ...). Upstash caches the result under that label. If the run later crashes - mid-send, say - and retries, it replays the cached subscription instead of hitting MongoDB again. It's a checkpoint: the workflow resumes just after it, no duplicate query, no duplicate side effect.
No subscription, a cancelled one, or a renewal date already in the past - the run logs why and exits without scheduling anything.
For each of 7, 5, 2 and 1 days before renewal, context.sleepUntil() suspends the entire workflow until that timestamp - no process, no timer held open in between - and then a reminder step runs. The send itself is a stub where an email or push notification would go.
A cron job polling the table would wake constantly and re-scan every subscription to find the few with a reminder due. A per-subscription durable workflow schedules exactly the four wake-ups it needs and nothing else.
Running an event-driven loop like this locally is normally the awkward part - the usual answer is tunnelling with ngrok. Upstash's QStash local mode replaces that: run the dev server, drop the local token and URL into .env, leave the signing keys for production, and the workflow ids show up in the console as they're generated.
One interop snag
The project is ESM ("type": "module"), so only import works - but @upstash/workflow/express ships as CommonJS. The workaround is to synthesise a require inside the module:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { serve } = require('@upstash/workflow/express');What I took away
- Atomic transactions - a MongoDB session that commits in full or aborts and leaves nothing behind, and the failure modes that trigger the abort (a violated constraint, a type mismatch, a bad query).
- What signing a JWT actually protects - integrity and authenticity - versus what it doesn't: the payload is readable by anyone, and transport and revocation are separate problems with separate fixes.
- The three-layer view of the whole thing: TLS for confidentiality in transit, the signature for integrity, hashing for confidentiality at rest.
- Why stateless auth lets an old token keep working, and the three ways around it - a Redis blocklist, a tokenVersion stamp, or short tokens with refresh - plus why a second browser tab is already logged in (the cookie store is shared across tabs on a domain).
- Mongoose lifecycle hooks, and why findByIdAndUpdate and other query-level writes skip them; populate() as a projected JOIN.
- Durable execution - checkpointing a read with context.run so a retry reuses it, and suspending a workflow for days with sleepUntil instead of a cron job or a long-lived timer.
- Express middleware runs top to bottom, so parsers precede security and the error handler comes last; and route order matters - a literal path has to be declared before a :param that would otherwise swallow it.
- Pulling a CommonJS package into an ESM project with createRequire.
Structure and stack follow JavaScript Mastery’s production-backend walkthrough. The annotations throughout the code - what each mechanism guarantees and where it stops - are my own. Source is linked above.
© 2026 Gaurav Divecha. All rights reserved.