Work · 04 of 9

CampusCritique

College discovery platform I co-founded. I own payments, notifications and admissions automation

Role
Co-founder; Connect payments and infra, notification layer, admissions automation, initial UI
Team
Nimit Jain, Ved Kumar Singh
Period
Apr 2026 to present
Status
production
Stack
  • Next.js 16
  • React 19
  • Supabase
  • PostgreSQL
  • Row-Level Security
  • Cashfree
  • QStash
  • Resend
  • Firebase
  • Vercel
Links
www.campuscritique.in
campuscritique.in, home, captured 2026-09-15
campuscritique.in, home. Captured from the live site on 2026-09-15.

Measured

MetricValueWindowSource
Users since launch, 18K events, launch June 20261.1KJun to Sep 2026Google Analytics 4, 14 Sep 2026
Paid sessions completed, 0 refunds needed, 2 reschedules handled15 of 15lifetimeconnect_sessions, Supabase, 14 Sep 2026
Organic search share, 46 organic vs 12 direct sessions53%last 7 daysGoogle Analytics 4, 14 Sep 2026
Mentors onboarded13lifetimementor_profiles, Supabase, 14 Sep 2026

Problem

Students choosing one of India’s new-age tech colleges get marketing from the college and rumours from YouTube. CampusCritique puts verified student reviews, structured college data, side-by-side compare and a community in one place, and Connect lets a prospective student book a paid one-to-one video call with a verified senior. Three of us co-founded it in April 2026; I am “Founder C” in the team’s docs, which meant payments, notifications, admissions automation and the initial UI.

The part of the product that could lose real money is Connect. A booking is a slot hold, a gateway order, a webhook that may arrive twice or out of order, a refund path with its own webhooks, and notifications to two people. Getting that right, and being able to prove it was right, is most of what follows.

From the repository

469Commits140 mine, 30%
40,383Lines of code243 source files
34MigrationsSQL, versioned
Counted from newgen on 2026-09-15, commits 2026-04-18 to 2026-08-07. Source lines only (no vendored, generated or built files). Three co-founders commit to this repository; the counts are for the whole team, my share is the first cell.

Next.js 16 App Router with React 19, Supabase for Postgres, auth, storage and Row-Level Security, 34 SQL migrations, and a private schema whose functions are reachable only through a server-only Postgres role (campuscritique_private_api), never from the browser. Connect alone is 15 API routes under app/api/connect, three cron routes, and a dozen typed notification emitters.

Connect: a booking that has to survive the real world

gatewayBooking pageslot hold, 15 mincreate-orderCashfree orderpayment-webhooksignature, idempotencySupabaseconnect_sessions, RLSNotifybooked, reminder, refundQStash crons2 schedules, 576 runs aday
Every side effect leaves the request path. The webhook does the minimum and returns.

Hold. Choosing a slot calls private.create_connect_pending_session, which checks the mentor’s slot is free, prices the session (₹59 for 15 minutes, ₹99 for 30) and holds the slot for 15 minutes. Holds are rows, not locks: a cleanup cron expires them, and create-order refuses to create a gateway order for a hold that has lapsed.

Order. The Cashfree order is tagged with the session id, so a webhook can still find its session if the order id lookup fails.

Webhook. The route reads the raw body before parsing anything, verifies HMAC-SHA256(secret, timestamp + body) with timingSafeEqual, and only then decides what kind of event it is. Refund events go to a refund handler with an already-processed guard. Payment events must match the session’s amount and currency to the paisa. A payment for a session that is already confirmed is answered duplicate: true and nothing else happens. A payment that arrives after the hold expired is recorded as cancelled with refund_status = 'required', because the money exists and the slot does not.

Confirm atomically. Confirmation is one SQL function, private.mark_connect_session_confirmed, called over the private role. It locks the session row, moves it to confirmed, writes the payment ids and the webhook event id, and books the slot, in one transaction. A retried webhook hits the “already confirmed” early return inside the function, not just in the route.

Notify, elsewhere. Every side effect that is not the confirmation itself is an event posted to Notify: booked, reminder two hours before, cancelled, rescheduled, refund processed, refund failed, review ready, payout. The Next.js client has a five-second timeout and degrades to a log line, so a notification outage can never fail a payment.

Try it

The lab below runs the webhook route’s decision path in your browser, including the signature check. Send a payment, send it again as a gateway retry, tamper with the signature, or switch the guard back to the version that broke production on 15 June.

Webhook labthe payment-webhook route, decision by decision
RequestPOST /api/connect/payment-webhook
x-webhook-timestamp
1750001234
x-webhook-signature
computing…
{
 "type": "PAYMENT_SUCCESS_WEBHOOK",
 "data": {
  "order": {
   "order_id": "order_cc_9d41",
   "order_amount": 99,
   "order_currency": "INR",
   "order_tags": {
    "connect_session_id": "6b1e…c2a7"
   }
  },
  "payment": {
   "cf_payment_id": 5140021973,
   "payment_status": "SUCCESS",
   "payment_amount": 99,
   "payment_currency": "INR"
  }
 }
}
connect_sessions rowpending
id
6b1e…c2a7
amount
9900 paise (INR)
payment_id
null
refund_status
null
webhook_event_id
null
mentor_slots
held
0 deliveries

The signature is computed in your browser with a demo secret so you can see the mechanism; the real secret lives only in Vercel. Idempotency: the route derives an event id from x-idempotency-key, then x-webhook-timestamp, then a SHA-256 of the body, and the confirmation function is a no-op for a session that is already confirmed, so a retried delivery cannot book twice or notify twice.

15 June, in order

The Connect payment flow shipped on 7 June with Cashfree. Eight days later, adding refund webhooks introduced a guard that checked whether the refund event had a type at all. Every Cashfree webhook has a type, so every payment success was routed to the refund handler, which could not find a session by refund order id and politely returned 200 ignored. Cashfree saw success and did not retry. Any payment in that window would have confirmed nothing: the session stays pending until the hold cleanup cancels it, and the money has to be refunded by hand.

The commit log for that evening: the refund webhook feature, then “three payment flow bugs: phone fallback, refund guard race, confirmation atomicity”, a revert and a reapply around a teammate’s email-template merge, and at 23:17 the one-line fix, refundEvent.event to refundEvent.isSuccessful || refundEvent.isFailed. The next day’s work was the consequence: reminders reduced to one at two hours, cron cleanup to two QStash schedules, and a launch security hardening migration that moved every privileged write behind SECURITY DEFINER functions on a private schema.

What I took from it is in the webhook article: a guard on the shape of a payload is not a guard on its meaning, and a webhook that answers 200 for something it did not understand is a bug that hides itself.

Timeline

  1. First commit: NewGenCollege foundation Next.js scaffold, first college pages.
  2. Repository refactor for production and an AI context brain branching strategy, handoff logs, Supabase MCP.
  3. Campus-specific short slugs with redirects
  4. Notify bell, push opt-in, moderation and community triggers first events into the notification service.
  5. Admission date automation and alerts
  6. Connect on Razorpay with the refund policy.
  7. Switched to Cashfree; crons moved to QStash profile gate, photo upload, admin refunds.
  8. Reschedule API, migration and notifications
  9. Refund webhooks, three payment-flow fixes, the guard bug and its fix at 23:17
  10. One reminder at 2 h; cron cleanup to 2 schedules, 576 runs a day
  11. Launch security hardening migration RLS on every Connect table, privileged writes behind private functions.
  12. 15 of 15 paid sessions completed, zero refunds needed
My commits only, from git log.

Before and after

BeforeAfter
GatewayRazorpay, refund policy did not fit their complianceCashfree, with a reschedule-first refund policy the gateway accepts
15 June incidentA refund guard routed every payment webhook to the refund handlerOne-line fix, then three follow-ups the same day: phone fallback, refund-guard race, confirmation atomicity
CronsSeveral Vercel schedules, some overlappingTwo QStash schedules, 576 runs a day, idempotent
NotificationsEmail calls inside API routesEvents to Notify: 1,346 jobs delivered at 98.1%
Privileged writesBrowser calling SECURITY DEFINER functionsAuthenticated API routes calling a private schema through a least-privilege role

Numbers, honestly

Thirteen verified mentors, 43 sessions created, 15 paid and all 15 completed, two reschedules handled, zero refunds needed. 1.1K users and 18K events since the June launch, with organic search now the largest channel at 53% of sessions. Small, real, and every paid session completed. Gross merchandise value is tiny at test-phase pricing and is not a number worth headlining.

Also mine

Admission-date automation and alerts, compare data and college alerts, the initial UI and first pages, the migration of every runtime image to Supabase Storage, and the Connect end-to-end audit and release QA checklists.

Learned: Idempotent webhooks or nothing: gateways retry, out of order, at 2am.

keysKeyboard
j / k
next / previous row
Enter
open the focused row
⌘K or /
search, or ask this site
g then h w a c
go home, work, about, contact
t
toggle light and dark
Esc
close the palette or this map
?
this map