Skip to content

API Reference

Part: saas-boilerplate/ | Type: REST API Documentation Generated: 2026-02-12 | Last Verified: 2026-02-25 | Scan Level: Exhaustive Base URL: http://localhost:5500/api/v1 Authentication: JWT Bearer Token or Cookie Content-Type: application/json (unless noted as multipart/form-data) Total Endpoints: 207 unique routes across 30 modules (verified 2026-02-25)

Authentication

Protected endpoints accept the JWT token via either:

  1. Authorization header: Authorization: Bearer <jwt_token>
  2. Cookie: token (access token), refresh_token (refresh token)

Cookies take priority over the Authorization header.

Note: The code does not validate that the Authorization header prefix is literally "Bearer" — any prefix followed by a space will work.

Response Format

Success Response

{
  "success": true,
  "message": "Operation successful",
  "meta": null,
  "data": { ... }
}

All responses include a meta field (set to null when not paginated).

Paginated Response

{
  "success": true,
  "message": "...",
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 100
  },
  "data": [...]
}

Note: The meta type does not include a totalPages field. Clients must calculate it as Math.ceil(total / limit).

Error Response

{
  "success": false,
  "message": "Error description",
  "error": { ... }
}

The error field shape varies by error type (ApiError, ZodError, StripeError). The 404 handler uses { path, message } but the global error handler passes the raw error object.

Rate Limiting

Rate limiting is applied per-route only — there is no global rate limiter.

Endpoint Window Max Requests
POST /auth/login 1 minute 5 per IP
POST /auth/register 1 minute 3 per IP
POST /auth/login-otp 15 minutes 5 per IP
POST /auth/verify-login-otp 15 minutes 10 per IP

Known issue: Code comments in auth.route.ts for the login rate limiter incorrectly say "15 minutes" and "3 requests" but actual values are 1 minute and 5 requests.


Auth Endpoints

All auth routes are public (no auth() middleware at route level).

POST /auth/register

Register a new user account. Rate limited: 3 req/1min.

Request Body:

{
  "name": "John Doe",
  "email": "[email protected]",
  "password": "securePassword123"
}

Validation: password min 6 chars.

Response: Sends verification email. data: null.

POST /auth/login

Authenticate user and receive JWT token. Rate limited: 5 req/1min.

Request Body:

{
  "email": "[email protected]",
  "password": "securePassword123",
  "rememberMe": false
}

Validation: password min 6 chars. rememberMe optional, defaults to false.

Standard Response (when 2FA not enabled):

{
  "success": true,
  "message": "Login successfully",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "user": {
      "id": 1,
      "email": "[email protected]",
      "name": "John Doe",
      "role": "user",
      "permissions": ["user.view", "blog.view"],
      "subscription": {
        "status": "active",
        "package": "Pro",
        "billingCycle": "monthly"
      }
    }
  }
}

Note: In the login response, role is a string (the role name). In the GET /users/profile response, role is an object { id, name }. These are different shapes for the same concept.

Note: billingCycle inside subscription is resolved at runtime from the associated Package entity — it is not a column on the Subscription entity.

The refresh token is set as an httpOnly cookie (refresh_token), not returned in the response body.

2FA Response (Email OTP):

{
  "success": true,
  "message": "OTP sent to your email for verification",
  "data": { "method": "otp_verification" }
}

2FA Response (Google Authenticator):

{
  "success": true,
  "message": "Please provide your 2FA code",
  "data": { "method": "google_auth" }
}

POST /auth/verify-otp

Verify OTP for 2FA login (Email OTP method).

Request Body:

{
  "email": "[email protected]",
  "otp": "123456"
}

Validation: otp must be exactly 6 characters.

Response data:

{
  "user": { "id": 1, "email": "...", "name": "...", "role": "user", "permissions": [...], "subscription": {...} },
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

Known issue: A refresh token is generated by the service but is NOT returned in the response body and NOT set as a cookie. The refresh token is silently discarded.

POST /auth/verify-2fa-google

Verify Google Authenticator TOTP for 2FA login.

Request Body:

{
  "email": "[email protected]",
  "token": "123456"
}

Validation: token must match regex /^\d{6}$/.

Known issue: This endpoint returns refreshToken in the JSON response body (unlike standard login which sets it as a cookie only).

POST /auth/verify-backup-code

Verify backup code for 2FA login when primary method unavailable.

Request Body:

{
  "email": "[email protected]",
  "code": "ABCD-1234-EFGH"
}

Note: The field is code, not backupCode.

Response data:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "user": { "id": 1, "email": "...", "name": "...", "role": "user", "permissions": [...], "subscription": {...} }
}

Known issue: The refreshToken is returned in the JSON body (not set as an httpOnly cookie like the standard login flow). Auth cookies are NOT set by this endpoint — the client must handle token storage manually.

POST /auth/verify-email

Verify email registration token.

Request Body:

{
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

POST /auth/forgot-password

Request password reset email.

Request Body:

{
  "email": "[email protected]"
}

POST /auth/reset-password

Reset password with token.

Request Body:

{
  "token": "abc123...",
  "password": "newSecurePassword123"
}

Validation: password min 3 chars (inconsistent with register's min 6).

POST /auth/login-otp

Request OTP for passwordless login. Rate limited: 5 req/15min.

Request Body:

{
  "email": "[email protected]"
}

POST /auth/verify-login-otp

Verify OTP and login (creates user if new). Rate limited: 10 req/15min.

Request Body:

{
  "email": "[email protected]",
  "otp": "123456"
}

POST /auth/refresh-token

Refresh access token. Reads the refresh token from the refresh_token cookie (not from request body).

Response:

{
  "success": true,
  "message": "Token refreshed successfully",
  "data": { "token": "new_access_token..." }
}

POST /auth/logout

Logout and invalidate session. No auth middleware is applied at route level — uses Passport's req.logout() internally.

Google OAuth Endpoints

GET /auth/google

Initiate Google OAuth flow. Redirects to Google login.

GET /auth/google/callback

Google OAuth callback. Handles authentication and redirects to frontend.

GET /auth/profile

Get profile from OAuth session. No JWT auth guard — checks req.isAuthenticated() (Passport session) internally. Returns { success, user, token } directly via res.json() (not using sendResponse wrapper).

GET /auth/failure

OAuth failure handler. Redirects to frontend login with error parameter.


User Endpoints

GET /users/profile

Get current user profile. Auth required (inline auth()).

Response data:

{
  "id": 1,
  "name": "John Doe",
  "email": "[email protected]",
  "avatar": "https://...",
  "phone": "+1234567890",
  "address": "123 Main St",
  "role": { "id": 2, "name": "user" },
  "userRoles": [{ "id": 2, "name": "user" }],
  "permissions": ["user.view", "blog.view"],
  "subscription": {
    "status": "active",
    "package": { "id": 1, "name": "Pro", "billingCycle": "monthly" }
  }
}

permissions is not a User column — it is resolved at runtime from the user's Role -> Permission relations. Format is dot-notation (e.g., "user.view"), not URL paths.

GET /users/my-permissions

Get current user's permissions list. Auth required.

Response data: flat array of permission strings, e.g., ["user.view", "blog.view", "role.create"].

PATCH /users/profile

Update current user's profile. Auth required. multipart/form-data.

Fields: name, email, phone, address, image (file), oldPassword, newPassword.

Validation: oldPassword and newPassword min 6 chars when provided.

SECURITY WARNING: The validation schema (updateUserValidation) is a .partial() of the admin createUserValidation, which means role, userRoles, status, and isInviteOnly also pass validation. The service at user.service.ts line 533 blindly applies payload.role to user.roleId. Any authenticated user can change their own role to any role ID (including admin) via this endpoint. This is a privilege-escalation vulnerability. Until fixed, do not expose the role field to frontend profile forms.

GET /users

List all users. Auth + Permission required (route-level permission("user.view")).

Query Parameters: page, limit, searchTerm, role, status, email, name, permission, sort, trashOnly ("true"/"false").

POST /users

Create new user. Auth required. Permission checked in service via checkPermissionAndThrow("user.create").

Request Body:

{
  "name": "Jane Doe",
  "email": "[email protected]",
  "password": "password123",
  "role": 2,
  "userRoles": [3, 4],
  "phone": "+1234567890",
  "address": "123 Main St",
  "isInviteOnly": false,
  "status": "active"
}

Validation: name, email, role required. password optional (min 6), required when isInviteOnly is false. status enum: active|inactive|suspend.

GET /users/:id

Get user by ID. Auth required. Permission checked in service.

PATCH /users/:id

Update user. Auth required. Permission checked in service. Same body as POST (all fields optional).

DELETE /users/:id

Soft delete user (moves to trash). Auth required. Permission checked in service.

DELETE /users/:id/permanent

Permanently delete user. Auth required. Permission checked in service.

PATCH /users/:id/restore

Restore user from trash. Auth required. Permission checked in service.

PATCH /users/bulk-delete

Soft delete multiple users. Auth required. Permission checked in service.

Request Body:

{
  "userIds": [1, 2, 3]
}

Note: Field is userIds, not ids.

PATCH /users/bulk-restore

Restore multiple users from trash. Auth required.

Request Body:

{
  "ids": [1, 2, 3]
}


Role Endpoints

All role routes require Auth (via router.use(auth(), contextMiddleware)). Permission checks are in the service layer.

GET /roles

List all roles. Permission: role.view.

Query Parameters: page, limit, searchTerm, id, name, orderBy, order, trashOnly.

GET /roles/:id

Get role with permissions. Permission: role.read.

POST /roles

Create role.

Known issue: Permission check role.create is commented out in role.service.ts line 150. Any authenticated user can create roles.

Request Body:

{
  "role": "editor",
  "displayName": "Content Editor",
  "permissionIds": [1, 2, 3]
}

Validation: role required (forbidden values: "user", "admin", "super-admin", "super_admin"). displayName required (3-50 chars). permissionIds optional array of numbers.

PATCH /roles/:id

Update role. Permission: role.update.

Note: displayName accepted by controller but NOT in update validation schema.

Request Body:

{
  "role": "editor",
  "permissionIds": [1, 2, 3]
}

Validation: role required. permissionIds required array.

DELETE /roles/:id

Soft delete role. Permission: role.delete.

PATCH /roles/bulk-delete

Bulk soft delete roles. Permission: role.delete.

Request Body: { "ids": [1, 2, 3] }

PATCH /roles/bulk-restore

Bulk restore roles. Permission: role.restore.

Request Body: { "ids": [1, 2, 3] }

PATCH /roles/:id/restore

Restore single role from trash. Permission: role.restore.

DELETE /roles/:id/permanent

Permanently delete role. Permission: role.delete.


Permission Endpoints

All routes require Auth (via router.use(auth(), contextMiddleware)). Many routes have redundant inline auth.

Known issue: Route ordering — GET /:permissionId is defined before GET /user-permissions, so the latter is unreachable (Express matches "user-permissions" as :permissionId).

GET /permissions/all

Get permissions with pagination. Permission: permission.view.

Query Parameters: page, limit, searchTerm, sortBy, sortOrder, trashOnly.

Despite the /all name, this endpoint IS paginated — unlike other /all routes in the system.

GET /permissions/:permissionId

Get single permission by ID. Permission: permission.read.

GET /permissions

Get ALL permissions (not paginated). Returns grouped/formatted list. Permission: permission.view.

GET /permissions/user-permissions

Get current user's permissions.

Unreachable — caught by GET /:permissionId route defined above it.

POST /permissions/check

Check if current user has a specific permission.

Request Body: { "name": "user.view" }

POST /permissions

Create permission. Permission: permission.create.

Request Body:

{
  "name": "custom.feature",
  "displayName": "Custom Feature Access",
  "readonly": false
}

Validation: name required (nonempty). displayName optional. readonly optional boolean.

Note: The field is name, which maps to the route column in the Permission entity.

PATCH /permissions/:permissionId

Update permission. Permission: permission.update.

Request Body: Same as create.

PUT /permissions/grant

Grant permissions to a role.

Request Body:

{
  "roleId": 1,
  "permissionIds": [1, 2, 3]
}

PATCH /permissions/bulk-delete

Bulk soft delete permissions. Permission: permission.delete.

Request Body: { "ids": [1, 2, 3] }

PATCH /permissions/bulk-restore

Bulk restore permissions. Permission: permission.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /permissions/delete

Delete permissions by IDs (body). Permission: permission.delete.

Request Body: { "ids": [1, 2, 3] }

DELETE /permissions/:permissionId

Soft delete single permission by ID. Permission: permission.delete.

PATCH /permissions/:permissionId/restore

Restore permission from trash. Permission: permission.restore.

DELETE /permissions/:permissionId/permanent

Permanently delete permission. Permission: permission.delete.


Package Endpoints

GET /packages

List subscription packages. Public.

Query Parameters: page, limit, searchTerm, name, id, category, isActive, isFree, billingType, trashOnly, sortBy, sortOrder.

Note: Filter by billing cycle uses query param billingType (not billingCycle).

GET /packages/:id

Get package by ID or slug. Public. Accepts both numeric IDs and slug strings.

POST /packages

Create package. Auth required. Permission: package.create (service-level).

Request Body:

{
  "name": "Pro Plan",
  "type": "subscription",
  "price": 29.99,
  "isFree": false,
  "description": "Professional plan",
  "isActive": true,
  "features": ["Feature 1", "Feature 2"],
  "billingCycle": "monthly",
  "stripePriceId": "price_xxx",
  "lemonVariantId": "variant_xxx"
}

Validation: name, type, price, billingCycle required. billingCycle enum: trial|monthly|yearly. durationInDays is auto-calculated (trial=14, monthly=30, yearly=365) — not a body field.

PATCH /packages/:id

Update package. Auth required. Permission: package.update. All fields optional.

DELETE /packages/:id

Soft delete package. Auth required. Permission: package.delete.

PATCH /packages/bulk-delete

Bulk soft delete. Auth required.

Request Body: { "ids": [1, 2, 3] }

PATCH /packages/bulk-restore

Bulk restore. Auth required.

Request Body: { "ids": [1, 2, 3] }

PATCH /packages/:id/restore

Restore single package from trash. Auth required.

DELETE /packages/:id/permanent

Permanently delete package. Auth required.


Package Category Endpoints

All routes require Auth.

GET /package-categories

List package categories. Permission: package-category.view.

POST /package-categories

Create package category. Permission: package-category.create.

Request Body: { "name": "Premium" }

PATCH /package-categories/bulk-restore

Bulk restore package categories. Permission: package-category.restore.

Request Body: { "ids": [1, 2, 3] }

PATCH /package-categories/:id

Update package category. Permission: package-category.update.

Request Body: { "name": "Updated Name" }

DELETE /package-categories/:id

Delete package category. Permission: package-category.delete.


Subscription Endpoints

All routes require Auth.

GET /subscriptions

List subscriptions. Permission: subscription.view (service-level).

Query Parameters: page, limit, searchTerm, status (enum: active|expired|pending|cancelled), startDate, endDate, sortBy, sortOrder.

GET /subscriptions/me

Get current user's subscription. Uses authGuard("user")restricted to "user" role only (admins get 403).

Query Parameters: page, limit, searchTerm, status, startDate, endDate, sortBy, sortOrder.

POST /subscriptions/subscribe

Create subscription checkout. Requires requireStripe middleware.

Request Body:

{
  "packageId": 1,
  "couponCode": "SAVE10",
  "paymentGateway": "stripe"
}

Validation: packageId (integer, required), couponCode (string, optional), paymentGateway (enum: stripe|lemonsqueezy, required).

Response data:

{
  "url": "https://checkout.stripe.com/...",
  "sessionId": "cs_xxx"
}

Note: Response key is url, not checkoutUrl. The paymentGateway field is validated but currently ignored — code hardcodes Stripe.


Payment Endpoints

GET /payments

List all payments. Auth + Permission required (auth(), hasPermission() middleware). Permission also checked in service: payment.view.

POST /payments/stripe/customer-portal-session

Create Stripe billing portal session. Auth required.

Request Body:

{
  "customerStripeId": "cus_xxx"
}

Validation: customerStripeId required, string, 1-255 chars.


Purchase Endpoints

GET /purchases

List purchases. Auth required (no permission check).

Known issue: No permission middleware or service-level check. Any authenticated user can list all purchases.

GET /purchases/me

Get current user's purchase history. Uses authGuard("user")restricted to "user" role only.


Coupon Endpoints

All routes require Auth.

GET /coupons

List all coupons. Permission: cupon.view.

GET /coupons/:id

Get coupon by ID. Permission: cupon.read.

POST /coupons

Create coupon. Permission: coupon.create.

Known issue: Permission strings use inconsistent spelling — "cupon.view", "cupon.read", "cupon.delete" vs "coupon.create", "coupon.update".

Request Body:

{
  "code": "SAVE10",
  "description": "10% off",
  "discountType": "percent",
  "discountValue": 10,
  "currency": "USD",
  "duration": "once",
  "durationInMonths": null,
  "isActive": true
}

Validation: code, discountType (percent|fixed), discountValue, duration (once|repeating|forever) required. If discountType is percent, max value is 100. If duration is repeating, durationInMonths is required.

PATCH /coupons/:id

Update coupon. Permission: coupon.update. All fields optional with same refinements.

DELETE /coupons/:id

Delete coupon. Permission: cupon.delete.


Blog Endpoints

Permission model: Blog endpoints use a hybrid ownership model, not standard checkPermissionAndThrow. All hard permission checks (checkPermissionAndThrow) are commented out in blog.service.ts except for PATCH /blogs/bulk-update-category. Instead, the code uses hasPermission() with global.blog-* strings for ownership-based access filtering: - global.blog-view — if absent, user only sees their own blogs - global.blog-edit — if absent, user can only edit their own blogs - global.blog-delete / global.blog-permanent-delete — if absent, user can only delete their own blogs - global.blog-restore — if absent, user can only restore their own blogs

These global.blog-* permissions control scope, not access denial — users without them are still allowed to operate but only on their own content.

GET /blogs

List blogs. Auth required (has inline auth() + contextMiddleware + sanitizeQuery). Uses hasPermission("global.blog-view") for scope filtering.

Query Parameters: page, limit, searchTerm, category, tag, status, isTrending, isFeatured, showOnSlider, trashOnly, date.

Author filtering is automatic based on user permissions — users without global.blog-view only see their own blogs.

GET /blogs/:id

Get blog by ID or slug. Public. Accepts both numeric IDs and slug strings. If the parameter is all digits (e.g., GET /blogs/42), looks up by ID. Otherwise (e.g., GET /blogs/my-blog-post), looks up by slug.

POST /blogs

Create blog post. Auth required. Uses hasPermission("global.blog-edit") for scope. multipart/form-data.

Fields:

{
  "title": "My Blog Post",
  "content": "<p>Content here...</p>",
  "excerpt": "Short description",
  "categories": [1, 2],
  "tags": [1, 2],
  "authorName": "John Doe",
  "status": "draft",
  "metaTitle": "SEO Title",
  "metaDescription": "SEO Description",
  "imageAlt": "Image description",
  "date": "2026-02-25",
  "isTrending": false,
  "isFeatured": false,
  "showOnSlider": false,
  "metaKeywords": ["keyword1", "keyword2"]
}

File field: image (single file upload via upload.single("image")).

Validation: title (3-200 chars), content (min 10), categories (array of numbers, min 1 item), authorName (min 2 chars) required. status enum: draft|published (validation only allows these two; entity TypeScript type also includes "archived" but it cannot be set via create/update — only used as a query filter for GET /blogs).

Note: slug is NOT accepted in the request body. categoryId (singular) does not exist — use categories (array, ManyToMany relation). Image is a file upload, not a URL string.

PATCH /blogs/:id

Update blog. Auth required. Uses hasPermission("global.blog-edit") for scope. multipart/form-data. All fields optional.

PATCH /blogs/:id/restore

Restore blog from trash. Auth required. Uses hasPermission("global.blog-restore") for scope.

PATCH /blogs/bulk-update-category

Bulk move blogs between categories. Auth required. Permission: blog.update (only blog endpoint with an active checkPermissionAndThrow).

Request Body:

{
  "newCategoryId": 2,
  "oldCategoryId": 1,
  "blogIds": [1, 2, 3]
}

PATCH /blogs/bulk-delete

Soft delete multiple blogs. Auth required. Uses hasPermission("global.blog-delete") for scope.

Request Body: { "blogIds": [1, 2, 3] }

Note: Field is blogIds, not ids.

PATCH /blogs/bulk-restore

Restore multiple blogs from trash. Auth required. Uses hasPermission("global.blog-restore") for scope.

Request Body: { "ids": [1, 2, 3] }

DELETE /blogs/:id

Soft delete blog. Auth required. Uses hasPermission("global.blog-delete") for scope.

DELETE /blogs/:id/permanent

Permanently delete blog. Auth required. Uses hasPermission("global.blog-permanent-delete") for scope.


Blog Category Endpoints

All routes require Auth.

GET /blog-categories/all

Get all categories (no pagination). Permission: blog-category.view.

GET /blog-categories

List categories (paginated). Permission: blog-category.view.

Query Parameters: page, limit, searchTerm, trashOnly.

GET /blog-categories/:id

Get category by ID. Permission: blog-category.read.

POST /blog-categories

Create category. Permission: blog-category.create.

Request Body:

{
  "name": "Technology",
  "description": "Tech articles",
  "slug": "technology",
  "serial": 1
}

Validation: name required. description, slug, serial optional.

Known issue: parentId in create validation is outside the body wrapper — effectively dead validation.

PATCH /blog-categories/:id

Update category. Permission: blog-category.update.

Request Body: name, description, serial, parentId (all optional).

PATCH /blog-categories/:id/restore

Restore from trash. Permission: blog-category.restore.

PATCH /blog-categories/bulk-delete

Bulk soft delete. Permission: blog-category.delete.

Request Body: { "blogIds": [1, 2, 3] }

Note: Field is blogIds (naming inconsistency — these are category IDs).

PATCH /blog-categories/bulk-restore

Bulk restore. Permission: blog-category.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /blog-categories/:id

Soft delete category. Permission: blog-category.delete.

DELETE /blog-categories/:id/permanent

Permanently delete category. Permission: blog-category.delete.


Blog Tag Endpoints

All routes require Auth.

GET /blog-tags

List blog tags. Permission: blog-tag.view.

Query Parameters: page, limit, searchTerm, trashOnly.

GET /blog-tags/:id

Get blog tag by ID. Permission: blog-tag.read.

POST /blog-tags

Create blog tag. Permission: blog-tag.create.

Request Body: { "name": "JavaScript", "description": "JS articles", "slug": "javascript" }

Validation: name required. description, slug optional.

PATCH /blog-tags/:id

Update blog tag. Permission: blog-tag.update. All fields optional.

PATCH /blog-tags/:id/restore

Restore blog tag from trash. Permission: blog-tag.restore.

PATCH /blog-tags/bulk-delete

Bulk soft delete blog tags. Permission: blog-tag.delete.

Request Body: { "blogIds": [1, 2, 3] }

Note: Field is blogIds (naming inconsistency — these are tag IDs).

PATCH /blog-tags/bulk-restore

Bulk restore blog tags. Permission: blog-tag.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /blog-tags/:id

Soft delete blog tag. Permission: blog-tag.delete.

DELETE /blog-tags/:id/permanent

Permanently delete blog tag. Permission: blog-tag.delete.


Product Endpoints

GET /products

List products. Public.

GET /products/:id

Get product. Public.

POST /products

Create product. Auth required. Permission: products.create. multipart/form-data.

Fields:

{
  "name": "Widget",
  "description": "A great widget",
  "categoryId": 1,
  "price": 29.99,
  "originalPrice": "39.99",
  "currency": "USD"
}

File field: images (up to 10 files via upload.array("images", 10)).

Validation: name (1-100 chars), categoryId required. price non-negative. originalPrice string (not number) matching /^\d+(\.\d{1,2})?$/. currency 3 chars.

Known issue: originalPrice is validated as a string type, not a number. Also, there is no originalPrice column in the Product entity — only discountPrice (decimal) exists. The field may be silently dropped.

PATCH /products/:id

Update product. Auth required. multipart/form-data. All fields optional. No active permission check ("product.update" is commented out in service; note inconsistency with create which uses plural "products.create").

Known issue: Controller returns status 201 and "Product created successfully" instead of 200/"updated".

DELETE /products/:id

Delete product. Auth required. Uses isDeleted boolean flag (not TypeORM soft delete).

Known issue: No permission check exists (not even commented out). Any authenticated user can delete any product.


Product Category Endpoints

All routes require Auth.

GET /product-categories/all

Get all product categories (no pagination).

GET /product-categories

List product categories (paginated).

GET /product-categories/:id

Get product category by ID.

POST /product-categories

Create product category. Permission: product-category.create.

Request Body: { "name": "Electronics", "description": "Electronic products" }

Validation: name required. description optional.

PATCH /product-categories/:id

Update product category. Permission: product-category.update. Same fields, optional.

DELETE /product-categories/:id

Delete product category. Permission: product-category.delete.


Page Endpoints

GET /pages

List CMS pages. Public.

GET /pages/:id

Get page by ID or slug. Public. Accepts both numeric IDs and slug strings.

POST /pages

Create page. Auth required. Permission: page.create. multipart/form-data.

Fields:

{
  "title": "About Us",
  "description": "About our company",
  "status": "draft",
  "metaTitle": "About Us | MySaaS",
  "metaDescription": "Learn about our company",
  "metaKeywords": ["about", "company"]
}

File field: image (single file via upload.single("image")).

Validation: title (1-255 chars), description required. status enum: draft|published|archived, default draft.

PATCH /pages/:id

Update page. Auth required. Permission: page.update. multipart/form-data. All fields optional.

PATCH /pages/:id/restore

Restore page from trash. Auth required. Permission: page.restore.

PATCH /pages/bulk-delete

Bulk soft delete pages. Auth required. Permission: page.delete.

Request Body: { "pageIds": [1, 2, 3] }

PATCH /pages/bulk-restore

Bulk restore pages. Auth required. Permission: generic-page.restore.

Request Body: { "ids": [1, 2, 3] }

POST /pages/bulk-permanent-delete

Bulk permanently delete pages. Auth required. Permission: page.delete. Uses POST method.

Request Body: { "ids": [1, 2, 3] }

DELETE /pages/:id

Soft delete page. Auth required. Permission: page.delete.

DELETE /pages/:id/permanent

Permanently delete page. Auth required. Permission: page.delete.


All routes require Auth.

GET /menus

List menus. Permission: menu.view.

Query Parameters: page, limit, searchTerm, id, name, slug, orderBy, order, trashOnly.

GET /menus/:id

Get menu by numeric ID (not slug). Permission: menu.read. Returns menu with items and nested children.

Note: This endpoint only accepts numeric IDs. There is no slug-based lookup for menus.

POST /menus

Create menu. Permission: menu.create.

Request Body:

{
  "name": "Main Navigation",
  "slug": "main-nav",
  "locations": ["header", "footer"]
}

Validation: name required. slug, locations (string array) optional.

PATCH /menus/:id

Update menu. Permission: menu.update. name is still required (not partial).

PATCH /menus/:id/restore

Restore menu from trash. Permission: menu.restore.

PATCH /menus/bulk-delete

Bulk soft delete. Permission: menu.delete.

Request Body: { "ids": [1, 2, 3] }

PATCH /menus/bulk-restore

Bulk restore. Permission: menu.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /menus/:id

Soft delete menu. Permission: menu.delete.

DELETE /menus/:id/permanent

Permanently delete menu. Permission: menu.delete.


All routes require Auth.

Known issue: Permission strings use two naming formats — GET endpoints use menuItem.* (camelCase) while CUD endpoints use menu-item.* (kebab-case). Both are in the actual code.

GET /menu-items

List menu items. Permission: menuItem.view.

GET /menu-items/:id

Get single menu item by ID. Permission: menuItem.read.

POST /menu-items

Create menu items (batch). Auth required. Permission: menu-item.create.

Request Body:

{
  "items": [
    {
      "menuId": 1,
      "title": "Dashboard",
      "link": "/dashboard",
      "linkType": "static",
      "target": "_self",
      "iconClass": "LayoutDashboard",
      "color": "#333",
      "order": 1,
      "parentId": null,
      "permissions": ["/dashboard"],
      "postType": null,
      "postId": null,
      "parameters": null
    }
  ]
}

Note: The array must be wrapped in an items property. Required fields per item: menuId, title, link. postType enum: blogs|pages|categories|tags|packages.

PATCH /menu-items/bulk-update

Bulk update multiple menu items. Permission: menu-item.update.

Request Body: { "items": [{ "id": 1, "title": "Updated", ... }] }. Each item requires id. All other fields optional.

Note: permissions field is missing from the bulk update validation schema.

PATCH /menu-items/:id

Update single menu item. Permission: menu-item.update. All fields optional (flat body, not wrapped in items).

PATCH /menu-items/:id/restore

Restore menu item from trash. Permission: menu-item.restore.

PATCH /menu-items/bulk-delete

Bulk soft delete. Permission: menu-item.delete.

Request Body: { "ids": [1, 2, 3] }

PATCH /menu-items/bulk-restore

Bulk restore. Permission: menu-item.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /menu-items/:id

Soft delete menu item. Permission: menu-item.delete.

DELETE /menu-items/:id/permanent

Permanently delete menu item. Permission: menu-item.delete.


Settings Endpoints

GET /settings

List all settings. Public (no auth middleware).

GET /settings/:prefix/prefix

Get settings by key prefix (e.g., /settings/site/prefix fetches all keys starting with site.). Public.

Note: :prefix is a dynamic route parameter, not a literal value.

GET /settings/:id

Get setting by ID. Public.

POST /settings

Create setting. Auth required. Permission: setting.create. multipart/form-data (upload.single('image')).

No validation schema — setting.validation.ts is empty.

PATCH /settings/bulk-update

Bulk update settings. Auth required. Permission: setting.bulkUpdate.

PATCH /settings/:id

Update setting by ID. Auth required. Permission: setting.update. multipart/form-data.

DELETE /settings/:id

Delete setting. Auth required. Permission: setting.delete.


App Config Endpoints

All routes require Auth.

GET /app-configs

List all app configs. Permission: app_config.read.

GET /app-configs/:id

Get app config by ID. Permission: app_config.read.

POST /app-configs

Create app config. Permission: app_config.create.

Request Body:

{
  "key": "feature.enabled",
  "displayName": "Feature Toggle",
  "value": "true",
  "isEncrypted": false
}

Validation: key, displayName, value required (min 1 char). isEncrypted optional boolean.

PATCH /app-configs/:id

Update app config. Permission: app_config.update. All fields optional.

DELETE /app-configs/:id

Delete app config. Permission: app_config.delete.


Contact Endpoints

POST /contacts

Submit contact form. Public.

Request Body:

{
  "name": "John Doe",
  "email": "[email protected]",
  "details": "Hello, I have a question about..."
}

Validation: name (2-100 chars), email (valid email, max 255), details (10-5000 chars).

GET /contacts

List contact submissions. Auth required. Permission: contact.view.

Query Parameters: page, limit, searchTerm, status, orderBy, order, trashOnly.

GET /contacts/:id

Get contact by ID. Auth required. Permission: contact.read.

PATCH /contacts/:id

Update contact status. Auth required. Permission: contact.update.

Request Body: { "status": "read" } or { "status": "unread" }

PATCH /contacts/bulk-delete

Bulk soft delete contacts. Auth required. Permission: contact.delete.

Request Body: { "contactIds": [1, 2, 3] }

Validation: Max 100 items.

PATCH /contacts/bulk-restore

Bulk restore contacts. Auth required. Permission: contact.restore.

Request Body: { "ids": [1, 2, 3] }

POST /contacts/bulk-permanent-delete

Bulk permanently delete contacts. Auth required. Permission: contact.delete.

Request Body: { "ids": [1, 2, 3] }

DELETE /contacts/:id

Soft delete contact. Auth required. Permission: contact.delete.

DELETE /contacts/:id/permanent

Permanently delete contact. Auth required. Permission: contact.delete.

PATCH /contacts/:id/restore

Restore contact from trash. Auth required. Permission: contact.restore.


Upload Endpoints

POST /uploads/image

Upload image file. No auth required (public endpoint).

Request: multipart/form-data with image field.

Accepted formats: jpeg, jpg, png, gif, webp, avif only.

Response data:

{
  "url": "https://res.cloudinary.com/...",
  "publicId": "folder/filename"
}

Known issue: This endpoint has no authentication. Anyone can upload files.


Email Template Endpoints

All routes require Auth.

GET /email-templates/all

Get all templates (no pagination). Permission: email-template.view.

GET /email-templates

List email templates (paginated). Permission: email-template.view.

Query Parameters: page, limit, searchTerm, orderBy, order, trashOnly.

GET /email-templates/:id

Get template by ID. Permission: email-template.read.

POST /email-templates

Create email template. Permission: email-template.create.

Request Body:

{
  "title": "Welcome Email",
  "type": "welcome_email",
  "subject": "Welcome to Our Platform",
  "html": "<h1>Welcome</h1><p>Thanks for signing up.</p>"
}

Validation: title, type, subject, html all required (min 1 char). The elements field (JSONB array of BlockNote editor blocks) is stored alongside html but managed by the frontend editor.

PATCH /email-templates/:id

Update email template. Permission: email-template.update. All fields optional.

PATCH /email-templates/:id/restore

Restore from trash. Permission: email-template.restore.

PATCH /email-templates/bulk-delete

Bulk soft delete. Permission: email-template.delete.

Request Body: { "templateIds": [1, 2, 3] }

PATCH /email-templates/bulk-restore

Bulk restore. Permission: email-template.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /email-templates/:id

Soft delete template. Permission: email-template.delete.

DELETE /email-templates/:id/permanent

Permanently delete template. Permission: email-template.delete.


Public Endpoints

All routes are Public (no auth). These provide frontend-consumable data.

GET /public/blogs

List published blogs for public consumption.

GET /public/menus

List menus for public navigation.

GET /public/menu-items

Get menu items by type.

GET /public/home-page-blogs

Get home page blog data.

GET /public/top-blogs

Get top/popular blogs.

GET /public/blogs/featured-categories

Get categories with blog counts.

GET /public/blogs/:categoryNameOrSlug/featured

Get featured blogs by category name or slug.

GET /public/blogs/:id

Get single blog by ID or slug (public access).


Invite User Endpoints

Mounted at both /invite-users and /user-invites (dual mount — all routes accessible at either prefix).

POST /invite-users/check-invite

Check if invite exists for email. Public (before auth middleware).

Request Body: { "email": "[email protected]" }

GET /invite-users

List invited users. Auth required. Permission: invite.view.

GET /invite-users/:id

Get invite by ID. Auth required. Permission: invite.read.

POST /invite-users

Invite user by email. Auth required. Permission: invite.create.

Request Body:

{
  "email": "[email protected]",
  "status": "PENDING"
}

Validation: email required. status optional enum: PENDING|ACCEPTED.

PATCH /invite-users/:id

Update invite. Auth required. Permission: invite.update.

Request Body: email (optional), status (optional enum: PENDING|ACCEPTED).

PATCH /invite-users/:id/restore

Restore invite from trash. Auth required. Permission: invite.restore.

PATCH /invite-users/bulk-delete

Bulk soft delete invites. Auth required. Permission: invite.delete.

Request Body: { "ids": [1, 2, 3] }

PATCH /invite-users/bulk-restore

Bulk restore invites. Auth required. Permission: invite.restore.

Request Body: { "ids": [1, 2, 3] }

DELETE /invite-users/:id

Delete invitation. Auth required. Permission: invite.delete.

DELETE /invite-users/:id/permanent

Permanently delete invitation. Auth required. Permission: invite.delete.


User Setting Endpoints

All routes require Auth.

PUT /user-settings/2fa/email

Enable email-based 2FA.

PUT /user-settings/2fa/google/setup

Start Google Authenticator 2FA setup. Returns QR code and secret.

PATCH /user-settings/2fa/google/verify

Verify and activate Google Authenticator 2FA.

Request Body: { "token": "123456" }

Validation: token must match regex /^\d{6}$/.

PATCH /user-settings/2fa/google/disable

Disable Google Authenticator 2FA.

Request Body: { "token": "123456" }

POST /user-settings/2fa/generate-backup-codes

Generate new backup codes for 2FA recovery. No request body required (controller only uses req.user.id).

GET /user-settings

Get user settings list.

GET /user-settings/me

Get current user's settings.

GET /user-settings/:id

Get user setting by ID.


Analytics Endpoints

GET /analytics

Get dashboard statistics. Auth required.

Response data:

{
  "totalUsers": 150,
  "activeSubscriptions": 45,
  "totalRevenue": 12500,
  "recentSignups": [...]
}

Note: The path is /analytics/ (root), not /analytics/dashboard.


Slug Endpoint

POST /slugs

Generate a unique slug from a given string. Auth required.

Request Body:

{
  "tableName": "blogs",
  "slug": "my-blog-post"
}


Webhook Endpoints

Important: Webhook endpoints are registered at the root level, not under /api/v1. Mounted via app.use("/", webhookRouters) with /webhooks prefix.

POST /webhooks/stripe

Stripe webhook handler. Uses express.raw({ type: 'application/json' }) for body parsing.

Headers Required: stripe-signature

Handled Events: | Event | Status | |-------|--------| | checkout.session.completed | Active | | invoice.payment_succeeded | Handler commented out (no-op) | | payment_intent.payment_failed | Empty handler (no-op) | | customer.subscription.created | Active (via handleSubscriptionUpsert) | | customer.subscription.updated | Active (via handleSubscriptionUpsert) | | customer.subscription.deleted | Active |

POST /webhooks/lemonsqueezy

LemonSqueezy webhook handler.

Headers Required: X-Signature

Handled Events: | Event | Status | |-------|--------| | subscription_created | Active | | order_created | console.log only (no-op) | | order_updated | console.log only (no-op) | | subscription_updated | console.log only (no-op) | | subscription_cancelled | console.log only (no-op) |

Known issues: Webhook secret is hardcoded ("c3b451346788c32") instead of reading from config. Signature verification is duplicated. Function never sends a response (external caller receives no acknowledgment).


Swagger / API Explorer

Interactive API documentation is available at /api/v1/docs via Swagger UI.

  • URL: http://localhost:5500/api/v1/docs
  • Auth: Basic authentication (email + password login form)
  • Credentials: Configured via DOCS_EMAIL and DOCS_PASSWORD environment variables (defaults: [email protected] / password123)
  • Session: After initial login, authentication persists via Express session
  • JSON spec: Available at /api/v1/docs/swagger.json (also auth-protected)
  • Source: Reads from src/public/swagger.yaml

Security: The default credentials ([email protected] / password123) are insecure. Set DOCS_EMAIL and DOCS_PASSWORD before any non-local deployment.


Error Codes

Status Meaning
200 Success
201 Created
400 Bad Request (validation error)
401 Unauthorized (no/invalid token)
403 Forbidden (no permission)
404 Not Found
409 Conflict (duplicate)
429 Too Many Requests (rate limited)
500 Internal Server Error

Generated by BMAD Document Project workflow v1.2.0 — 2026-02-12 | Verified and rewritten against source code — 2026-02-25