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

Measured
| Metric | Value | Window | Source |
|---|---|---|---|
| Users since launch, 18K events, launch June 2026 | 1.1K | Jun to Sep 2026 | Google Analytics 4, 14 Sep 2026 |
| Paid sessions completed, 0 refunds needed, 2 reschedules handled | 15 of 15 | lifetime | connect_sessions, Supabase, 14 Sep 2026 |
| Organic search share, 46 organic vs 12 direct sessions | 53% | last 7 days | Google Analytics 4, 14 Sep 2026 |
| Mentors onboarded | 13 | lifetime | mentor_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
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
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.
- 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"
}
}
}- id
- 6b1e…c2a7
- amount
- 9900 paise (INR)
- payment_id
- null
- refund_status
- null
- webhook_event_id
- null
- mentor_slots
- held
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
- First commit: NewGenCollege foundation Next.js scaffold, first college pages.
- Repository refactor for production and an AI context brain branching strategy, handoff logs, Supabase MCP.
- Campus-specific short slugs with redirects
- Notify bell, push opt-in, moderation and community triggers first events into the notification service.
- Admission date automation and alerts
- Connect on Razorpay with the refund policy.
- Switched to Cashfree; crons moved to QStash profile gate, photo upload, admin refunds.
- Reschedule API, migration and notifications
- Refund webhooks, three payment-flow fixes, the guard bug and its fix at 23:17
- One reminder at 2 h; cron cleanup to 2 schedules, 576 runs a day
- Launch security hardening migration RLS on every Connect table, privileged writes behind private functions.
- 15 of 15 paid sessions completed, zero refunds needed
Before and after
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.