Skip to content

API Design & Error Conventions

This guideline is the canonical reference for how HTTP APIs are shaped in the mr-mentor-backend service: URL prefixing and resource naming, the auth header contract, the JSON response envelope, status-code usage, pagination/filtering, and how errors propagate from a controller catch (or a guard middleware) all the way back to the client. It is descriptive — it documents the conventions the codebase actually follows today (which are loose and manually enforced, not schema-validated), and flags where they diverge so future code can converge.

Status: documented from source on this branch.


Overview

mr-mentor-backend is a single Express + TypeScript monolith that fronts the entire MAS / Mr. Mentor product suite (mentorship, recruitment/Mr. Hire, LMS, Sales CRM, Finance, AI platform, vendor API). Every product feature is exposed as REST-ish JSON over HTTP, plus a Socket.IO layer for real-time meetings (out of scope for this doc — see request-lifecycle-and-middleware).

There is no API framework abstraction on top of Express: no express-validator, no zod/Joi/class-validator, no express-async-errors, no central response serializer. Conventions are upheld by copy-paste discipline across ~70 route files and ~42 controllers. This document captures that de-facto contract so it can be applied consistently.

Consumers (personas) of the API:

Tier Consumer Auth Mount examples
Public Anonymous browsers, marketing site, signup, quiz takers none (or a token is the resource) /api/health, /api/auth/*, /api/courses (public listing), /api/candidate-quiz/*, /api/judge0/*
Authenticated user Logged-in students, mentors/experts, sales, CMs Authorization: Bearer <JWT> (or cookie) /api/users/profile, /api/tokens/*, /api/student/*
Admin ADMIN / SUPERADMIN dashboard JWT + adminMiddleware /api/admin/*
Finance FINANCE (or ADMIN) JWT + financeOnlyMiddleware /api/finance/*
Vendor (machine) External partners ingesting leads X-API-Key: vk_live_xxx.yyy (or Bearer key) /api/v1/vendor/*
Webhook (machine) Exotel telephony callbacks shared-secret ?token= query param /api/exotel/*

Key concepts & entities

Term Meaning
Response envelope The JSON object shape returned by most handlers: { success, message, data, error } (not all fields always present).
Mount prefix The path Express prepends when a route group is mounted in src/routes/index.ts (e.g. /api, /api/admin, /api/v1/vendor). The path inside a route file is relative to its mount prefix.
Guard middleware A middleware that short-circuits with 401/403 before the controller runs (authMiddleware, adminMiddleware, etc.).
x-platform header Multi-tenant routing hint. Values seen: mr-mentor, my-analytics-school, mr-hire. Defaults to my-analytics-school when absent.
AuthenticatedRequest Request extended with req.user: User (the full DB row, not just the JWT claims).
Vendor key An API key shaped vk_live_<prefix>.<secret> stored hashed; carries canSubmit / canRead permissions.

Entities relevant to the API/error contract (paths under src/):

  • entities/User.ts — the principal attached as req.user; carries role, isActive, isVerified, passwordChangedAt, salesHeadId.
  • entities/RefreshToken.ts — session record; the JWT sid claim references this for per-session revoke.
  • entities/VendorApiKey.ts + entities/VendorApiKeyAccessSummary.ts — vendor auth + per-tuple access logging.
  • types/UserTypes.tsUserRole enum (USER, ADMIN, SUPERADMIN, EXPERT, SALES, FINANCE, …).

Source of truth for conventions:

  • src/app.ts — CORS, body limits, the 404 handler, and the global error handler.
  • src/routes/index.ts — every mount prefix.
  • src/middleware/auth.middleware.ts, admin.middleware.ts, finance.middleware.ts, expert.middleware.ts, vendorAuth.middleware.ts, totpRateLimit.middleware.ts.

Architecture

flowchart TD
    Client["Client (browser / vendor / webhook)"] -->|HTTP request| CORS["CORS + Helmet (app.ts)"]
    CORS --> BODY["express.json 20mb limit"]
    BODY --> ROUTER["Root Router (routes/index.ts)"]

    ROUTER -->|"prefix /api"| PUB["Public + user routes"]
    ROUTER -->|"prefix /api/admin"| ADM["Admin routes"]
    ROUTER -->|"prefix /api/finance"| FIN["Finance routes"]
    ROUTER -->|"prefix /api/v1/vendor"| VEN["Vendor routes"]
    ROUTER -->|"prefix /api/exotel"| WH["Webhook routes"]

    PUB --> GUARD{"Guard middleware?"}
    ADM --> GUARD
    FIN --> GUARD
    VEN --> GUARD
    WH --> GUARD

    GUARD -->|"401 / 403 short-circuit"| ERRJSON["JSON error response"]
    GUARD -->|pass| CTRL["Controller method (try/catch)"]

    CTRL --> SVC["Service layer"]
    SVC --> DB[("PostgreSQL via TypeORM")]
    SVC --> REDIS[("Redis / BullMQ")]
    SVC --> EXT["External APIs (Razorpay, S3, Exotel, LLM)"]

    CTRL -->|"res.status.json success envelope"| OK["2xx JSON"]
    CTRL -->|"caught error res.status.json"| ERRJSON
    CTRL -->|"uncaught throw (rare)"| GEH["Global error handler (app.ts) 500"]
    GEH --> ERRJSON

    ROUTER -->|"no route matched"| NF["404 handler (app.ts)"]
    NF --> ERRJSON

The dominant pattern is per-controller try/catch: nearly every handler catches its own errors and emits an envelope itself. The Express global error handler in src/app.ts is a backstop for uncaught synchronous throws — it is rarely reached because handlers are async and Express 4 does not forward rejected promises to the error handler automatically (there is no express-async-errors). An unhandled rejection inside an async handler that does not try/catch would hang the request rather than reach the global handler — this is the single most important gotcha (see Edge cases).


Data model

The API-contract concern owns very few entities; it mostly reads User and the auth/session/vendor records. This ER diagram shows the entities that shape auth and the request contract.

erDiagram
    USER ||--o{ REFRESH_TOKEN : "has sessions"
    VENDOR_API_KEY ||--o{ VENDOR_API_KEY_ACCESS_SUMMARY : "logs"

    USER {
        uuid id PK
        string email
        string role
        boolean isActive
        boolean isVerified
        datetime passwordChangedAt
        uuid salesHeadId FK
    }
    REFRESH_TOKEN {
        uuid id PK
        string token
        uuid userId FK
        boolean revoked
        string replacedByToken
        datetime expiresAt
    }
    VENDOR_API_KEY {
        uuid id PK
        string keyPrefix UK
        string keyHash
        string status
        boolean canSubmit
        boolean canRead
        datetime lastUsedAt
        int requestCount
    }
    VENDOR_API_KEY_ACCESS_SUMMARY {
        uuid id PK
        uuid vendorApiKeyId FK
        string ip
        string method
        string path
        int hitCount
        int lastStatusCode
    }

API surface

This domain doc does not enumerate every product endpoint (see each feature doc). Instead it documents the contract-defining routes and the mount prefixes. All paths below are the full path (mount prefix + route-file path), derived from src/routes/index.ts and the cited route files.

Method Path Auth/role Purpose
GET / public Banner JSON describing a few endpoints (src/app.ts).
GET /api/health public Liveness/health check (routes/health.routes.ts).
GET /api/test-data public Debug DB connectivity probe (defined inline in routes/index.ts).
POST /api/auth/signup public Register; sends OTP async (routes/auth.routes.ts).
POST /api/auth/verify-otp public Verify OTP, issue JWT + refresh cookies.
POST /api/auth/login public Login, issue tokens.
POST /api/auth/refresh public (refresh cookie) Rotate access token.
POST /api/auth/logout Bearer JWT Revoke current session.
POST /api/auth/master-totp public + totpRateLimit TOTP master-auth, 5/15min per IP.
GET /api/users/profile Bearer JWT Current user profile (envelope data).
GET /api/users Bearer JWT (admin-ish) Paginated user list (page,limit,total).
POST /api/users varies Create user; 409 on duplicate email.
GET /api/admin/... JWT + adminMiddleware All admin operations (routes/admin.routes.ts + others mounted at /api/admin).
GET /api/finance/overview JWT + financeOnlyMiddleware Finance portal (FINANCE or ADMIN).
POST /api/v1/vendor/leads X-API-Key + submit perm External vendor lead ingestion.
GET /api/v1/vendor/leads X-API-Key + read perm List vendor's leads.
GET /api/v1/vendor/leads/:id X-API-Key + read perm Fetch one vendor lead.
POST /api/exotel/call-status shared-secret ?token= Exotel telephony status webhook.
POST /api/exotel/sms-status shared-secret ?token= Exotel SMS status webhook.
ALL /api/candidate-quiz/* public (token-is-auth) Candidate quiz — the invite token authorizes.
ALL /api/judge0/* public Judge0 code-exec proxy, called from quiz pages.
* * (unmatched) 404 { success:false, message:"Route not found", path }.

Note: /api/v1/vendor is the only version-prefixed namespace in the codebase. Everything else is unversioned under /api. Admin endpoints are not a separate router so much as a separate mount prefix (/api/admin) — many different route classes are mounted there (adminRoutes, jobApplicationRoutes, resumeAnalysisRoutes, allResponsesRoutes, aiScreeningConfigRoutes, new-courses, vendor-keys, applicationMeetingAdminRoutes).

Request shape

A typical authenticated mutating request:

POST /api/tokens/purchase HTTP/1.1
Host: api.mrmentor.in
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
X-Platform: mr-mentor

{ "amount": 5, "couponCode": "WELCOME" }
  • Auth header: Authorization: Bearer <JWT>. The token may also arrive as a token or authToken cookie — authMiddleware checks the header first, then those cookies (src/middleware/auth.middleware.ts).
  • Tenant header: X-Platform selects the product tenant. Read per-handler; unknown/missing values fall back to my-analytics-school.
  • Vendor auth: X-API-Key: vk_live_<prefix>.<secret> or Authorization: Bearer <vendorKey>.
  • Body: JSON, up to 20mb (express.json({ limit: "20mb" })). Form bodies via express.urlencoded also up to 20mb.
  • CORS allowed headers: Content-Type, Authorization, X-Requested-With, X-Platform, X-Webhook-Secret (see src/app.ts).

Response envelope

The conventional success envelope (most controllers, e.g. src/controllers/user.controller.ts):

{
  "success": true,
  "message": "User profile retrieved successfully",
  "data": { "id": "…", "email": "…", "role": "USER" }
}

Paginated list envelope (getAllUsers in user.controller.ts):

{
  "success": true,
  "message": "Users retrieved successfully",
  "data": [ /* rows */ ],
  "total": 1234,
  "page": 1,
  "limit": 10
}

The conventional error envelope:

{
  "success": false,
  "message": "Failed to retrieve user profile",
  "error": "duplicate key value violates unique constraint"
}

Inconsistency to know about: the envelope is not universal. - authMiddleware / auth.routes handlers frequently return { message } without success (e.g. { message: "Authorization token missing" }, { message: "Signup successful…" }). - Some auth errors add reason or errorType discriminators (PASSWORD_CHANGED, SESSION_REVOKED, TOKEN_EXPIRED, INVALID_TOKEN). - Vendor and finance guards always include success: false. - The error field is the raw error.message and is included broadly in controllers (the global handler only leaks it when NODE_ENV=development).

When writing new endpoints, prefer the full { success, message, data } envelope and only attach error for diagnostic detail.

Pagination & filtering

There is no shared pagination helper. The de-facto convention (from user.controller.ts) is query params parsed inline:

const page  = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 10;
const [rows, total] = await service.getAll(page, limit); // TypeORM findAndCount

Returned as top-level total / page / limit siblings of data (not nested in a meta object). Filtering is ad-hoc per controller via additional query params (e.g. ?status=, ?search=) translated into TypeORM where clauses. There is no cursor pagination.


User journeys

Journey 1 — Authenticated request through the auth guard (happy path)

The most-traveled path: a logged-in user hits a protected endpoint. The guard re-validates the JWT and re-checks the DB row on every request (it does not trust the token claims alone).

sequenceDiagram
    participant FE as Frontend
    participant MW as authMiddleware
    participant DB as PostgreSQL
    participant CT as Controller
    participant SV as Service

    FE->>MW: GET /api/users/profile with Bearer token
    MW->>MW: extract token from header or cookie
    alt token missing
        MW-->>FE: 401 message Authorization token missing
    else token present
        MW->>MW: jwt.verify with HS256
        MW->>DB: load User by decoded id select id email role isActive
        alt user not found
            MW-->>FE: 401 message User no longer exists
        else account blocked
            MW-->>FE: 403 message account has been blocked
        else token predates password change
            MW-->>FE: 401 reason PASSWORD_CHANGED
        else session revoked
            MW-->>FE: 401 reason SESSION_REVOKED
        else valid
            MW->>CT: attach req.user then next
            CT->>SV: getUserById
            SV->>DB: query row
            DB-->>SV: row
            SV-->>CT: user
            CT-->>FE: 200 success true data user
        end
    end

Journey 2 — Controller error propagation to the client

Almost every controller wraps its body in try/catch and emits the error envelope itself. The global handler is only the backstop for the unusual synchronous throw.

sequenceDiagram
    participant FE as Frontend
    participant CT as Controller
    participant SV as Service
    participant DB as PostgreSQL
    participant GEH as Global error handler

    FE->>CT: POST /api/users body
    CT->>CT: validate fullName and email present
    alt missing required field
        CT-->>FE: 400 success false message fullName and email are required
    else duplicate email
        CT->>SV: emailExists check
        SV-->>CT: true
        CT-->>FE: 409 success false message Email already exists
    else service throws
        CT->>SV: createUser
        SV->>DB: insert
        DB-->>SV: throws constraint error
        SV-->>CT: rejected promise caught by try catch
        CT-->>FE: 500 success false message Failed to create user error detail
    end

    Note over GEH: reached only on an uncaught synchronous throw, returns 500 generic envelope

Journey 3 — Vendor machine-to-machine request (API key tier)

External partners authenticate with a hashed API key, not a JWT. The middleware runs a constant-time compare and logs the access asynchronously after the response finishes.

sequenceDiagram
    participant V as Vendor system
    participant VM as vendorAuthMiddleware
    participant DB as PostgreSQL
    participant CT as VendorLeadController

    V->>VM: POST /api/v1/vendor/leads X-API-Key vk_live_prefix.secret
    VM->>VM: parseVendorKey split prefix and secret
    alt malformed key
        VM-->>V: 401 success false message Missing or malformed API key
    else parsed
        VM->>DB: find VendorApiKey by keyPrefix
        VM->>VM: constant-time compare keyHash vs hash of secret
        alt no record or hash mismatch
            VM-->>V: 401 success false message Invalid API key
        else key not active
            VM-->>V: 403 success false message disabled or revoked
        else lacks submit permission
            VM-->>V: 403 success false message no submit permission
        else authorized
            VM->>CT: attach req.vendor then next
            CT->>DB: insert lead
            CT-->>V: 201 success true data lead
            Note over VM: on response finish, async upsert access summary and bump requestCount
        end
    end

Journey 4 — Webhook with shared-secret token (Exotel callback)

Telephony status callbacks cannot present a JWT, so they are guarded by a shared-secret token appended as a query param when the callback URL is registered (src/routes/exotelWebhook.routes.ts).

sequenceDiagram
    participant EX as Exotel
    participant WH as exotelWebhook route
    participant SV as ExotelService
    participant DB as PostgreSQL

    EX->>WH: POST /api/exotel/call-status with token query param
    WH->>SV: verifyWebhookToken token
    alt token invalid
        WH-->>EX: 403 success false message Invalid webhook token
    else valid
        WH->>SV: process call-status payload
        SV->>DB: update lead activity log
        WH-->>EX: 200 success true
        Note over WH: handler still try-catches, logs and returns error on failure
    end

Journey 5 — CORS preflight

Browsers send OPTIONS before cross-origin mutating calls. app.ts handles preflight both via the cors() middleware and an explicit app.options("*") handler returning 204.

sequenceDiagram
    participant FE as Browser
    participant CORS as cors and options handler

    FE->>CORS: OPTIONS /api/tokens/purchase Origin header
    alt origin allowed or NODE_ENV development
        CORS-->>FE: 204 with Allow-Origin Allow-Methods Allow-Headers
    else origin not allowed
        CORS-->>FE: error CORS not allowed
    end
    Note over CORS: allowed headers include Authorization X-Platform X-Webhook-Secret

Background jobs & async

The API contract intersects async work in two notable ways:

  • Fire-and-forget side effects in handlers. Some handlers return success immediately and run work in the background. Example: signUp responds 200 with "OTP (if email configured) is being sent." while the OTP send runs as a detached promise (src/controllers/auth.controller.ts). The client therefore must not treat a 200 as proof the email was sent.
  • BullMQ queues (emailQueue, databaseQueue, cleanupQueue, kpiQueue, resumeAnalysisQueue) — handlers enqueue jobs via QueueService and respond before the job runs. Queue health is observable at the Bull Board UI mounted at /admin/queues (routes/bullBoard.routes.ts).
  • Async access logging in vendorAuthMiddleware happens on res.on('finish') inside a setImmediate, so it never delays the response and its failures are swallowed with a console.warn.

There are no API-design-specific webhooks emitted by this service; inbound webhooks (Exotel, Razorpay) follow the shared-secret / signature patterns documented per integration.


External integrations

Relevant to the API/error layer:

Integration Used by API layer for Env vars Failure / fallback
JWT (jsonwebtoken) Bearer auth, HS256 verify JWT_SECRET Missing secret → 500 Server configuration error. Expired → 401 TOKEN_EXPIRED.
Redis TOTP rate limiting REDIS_HOST, REDIS_PORT totpRateLimit fails open — if Redis errors, the request proceeds (no 429).
PostgreSQL (TypeORM) every data read/write DB_* Errors surface as 500 with error.message in the envelope.
Exotel webhook token verify EXOTEL_WEBHOOK_TOKEN Bad token → 403; feature self-disables when unset.
Razorpay / S3 / LLM gateway downstream in services per-integration Wrapped in controller try/catch → 500 envelope.

CORS allow-list is data-driven: CORS_ALLOWED_ORIGINS (a JSON array) is merged with a hard-coded default list, and all origins are allowed when NODE_ENV=development (src/app.ts).


Status lifecycles

The HTTP request itself has a meaningful lifecycle through the middleware/handler stack:

stateDiagram-v2
    [*] --> CorsCheck
    CorsCheck --> Rejected: origin not allowed
    CorsCheck --> BodyParse: allowed
    BodyParse --> RouteMatch
    RouteMatch --> NotFound: no route
    RouteMatch --> Guard: route matched
    Guard --> Unauthorized: 401 missing or invalid auth
    Guard --> Forbidden: 403 wrong role or permission
    Guard --> RateLimited: 429 totp limit exceeded
    Guard --> Handler: passed
    Handler --> Success: 2xx envelope
    Handler --> ClientError: 400 or 404 or 409 caught
    Handler --> ServerError: 500 caught in try catch
    Handler --> GlobalHandler: uncaught sync throw
    GlobalHandler --> ServerError
    Success --> [*]
    ClientError --> [*]
    ServerError --> [*]
    NotFound --> [*]
    Unauthorized --> [*]
    Forbidden --> [*]
    RateLimited --> [*]
    Rejected --> [*]

The VendorApiKey status lifecycle gates the vendor tier:

stateDiagram-v2
    [*] --> ACTIVE: key issued
    ACTIVE --> DISABLED: admin disables
    DISABLED --> ACTIVE: admin re-enables
    ACTIVE --> REVOKED: admin revokes
    DISABLED --> REVOKED: admin revokes
    REVOKED --> [*]
    note right of ACTIVE
        only ACTIVE passes vendorAuthMiddleware
        DISABLED returns 403 disabled
        REVOKED returns 403 revoked
    end note

Status-code table

Code When it is used Source examples
200 OK Successful read/update; also used by signUp and many mutations user.controller.ts, auth.controller.ts
201 Created Resource created (createUser, vendor lead submit) user.controller.ts
204 No Content CORS preflight response app.ts options("*")
400 Bad Request Missing/invalid input, invalid OTP, signup failure controllers validate then 400
401 Unauthorized Missing/invalid/expired JWT, malformed/invalid vendor key, password-changed, session-revoked auth.middleware.ts, vendorAuth.middleware.ts
403 Forbidden Blocked account, wrong role (admin/finance/expert), disabled/revoked vendor key, bad webhook token admin.middleware.ts, finance.middleware.ts, vendorAuth.middleware.ts, exotelWebhook.routes.ts
404 Not Found Resource missing, or no route matched user.controller.ts, app.ts 404 handler
409 Conflict Duplicate unique resource (email already exists) user.controller.ts createUser
429 Too Many Requests TOTP attempts exceeded (5 per 15 min per IP) totpRateLimit.middleware.ts
500 Internal Server Error Caught service error, missing JWT_SECRET, vendor auth crash, global handler backstop controllers, app.ts global handler

Error-response table

Scenario Status Body Source
No auth token 401 { "message": "Authorization token missing" } auth.middleware.ts
Expired JWT 401 { "message": "Token expired", "errorType": "TOKEN_EXPIRED" } auth.middleware.ts
Invalid JWT 401 { "message": "Invalid or expired token", "errorType": "INVALID_TOKEN" } auth.middleware.ts
User deleted after token issued 401 { "message": "User no longer exists" } auth.middleware.ts
Password changed since token 401 { "message": "Your password was changed. Please login again.", "reason": "PASSWORD_CHANGED" } auth.middleware.ts
Session revoked elsewhere 401 { "message": "This session was signed out…", "reason": "SESSION_REVOKED" } auth.middleware.ts
Blocked account 403 { "message": "Your account has been blocked. Please contact support." } auth.middleware.ts
Not admin 403 { "message": "Access denied. Admin role required." } admin.middleware.ts
Not finance/admin 403 { "success": false, "message": "Finance access required" } finance.middleware.ts
Malformed vendor key 401 { "success": false, "message": "Missing or malformed API key. Expected X-API-Key: vk_live_xxxx.yyyy" } vendorAuth.middleware.ts
Invalid vendor key 401 { "success": false, "message": "Invalid API key" } vendorAuth.middleware.ts
Vendor key lacks permission 403 { "success": false, "message": "This key does not have submit permission" } vendorAuth.middleware.ts
Bad webhook token 403 { "success": false, "message": "Invalid webhook token" } exotelWebhook.routes.ts
TOTP rate limit 429 { "message": "Too many failed TOTP attempts. Try again later." } totpRateLimit.middleware.ts
Missing required body field 400 { "success": false, "message": "fullName and email are required" } user.controller.ts
Duplicate resource 409 { "success": false, "message": "Email already exists" } user.controller.ts
Unhandled service error 500 { "success": false, "message": "Failed to …", "error": "<detail>" } controllers
Route not found 404 { "success": false, "message": "Route not found", "path": "<url>" } app.ts
Backstop uncaught throw 500 { "success": false, "message": "Internal Server Error", "error?": "<detail in dev only>" } app.ts global handler

Edge cases, limits & gotchas

  • No automatic async error forwarding. Express 4 + no express-async-errors means a rejected promise in an async handler does not reach the global error handler. Every handler must wrap its body in try/catch or the request will hang until timeout. This is the single most important rule when adding endpoints.
  • The global error handler leaks error.message only in development. Most controllers, however, put the raw message in the error field unconditionally — be careful not to leak internals via that path.
  • Envelope is not enforced. Three shapes coexist: full { success, message, data }, bare { message } (auth/legacy), and { success, message } (guards). New code should use the full envelope.
  • Auth re-hits the DB on every request. authMiddleware does a findOne on User (and sometimes RefreshToken) per request — it does not trust JWT claims alone. This enables instant block/logout-everywhere/session-revoke at the cost of a DB read per call.
  • Token also accepted via cookie. Authorization: Bearer is preferred, but token / authToken cookies are honored, and login sets token, refreshToken, and a non-httpOnly user cookie. The token cookie is httpOnly:false by design (read by the frontend).
  • x-platform is read ad-hoc, not via middleware. Only a handful of controllers (auth, issues, voiceInterview, missOzone) inspect it. Default is my-analytics-school; mr-mentor and mr-hire are the other known values. There is no central platform-resolution middleware on this branch (inferred — a Platform entity / MasGateway exist but are not wired into a request guard here).
  • Rate limiting fails open. totpRateLimit lets requests through if Redis is unavailable — availability is favored over strict throttling. It is the only rate limiter in the stack; there is no global request throttle.
  • /api/v1/vendor is the only versioned namespace. All other routes are unversioned; breaking changes to /api/* must be coordinated across consumers (see Cross-Repo Dependencies).
  • No idempotency keys. Mutating endpoints are not idempotent and do not honor an Idempotency-Key header; retries on POST can double-write. Razorpay/order flows guard against this at the service layer, not the API layer.
  • req.ip is unreliable. trust proxy is not configured, so client-IP logic (vendor logging, TOTP keys) reads X-Forwarded-For / CF-Connecting-IP manually. Behind a new proxy hop this needs review.
  • Body limit is 20mb for both JSON and urlencoded — large uploads use S3 presigned URLs instead of posting bytes through this API.
  • /api/test-data is a public debug probe that reveals a seed user's name and role; it is a known leftover (TODO: gate or remove).