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/v1Authentication: JWT Bearer Token or Cookie Content-Type:application/json(unless noted asmultipart/form-data) Total Endpoints: 207 unique routes across 30 modules (verified 2026-02-25)
Authentication¶
Protected endpoints accept the JWT token via either:
- Authorization header:
Authorization: Bearer <jwt_token> - 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¶
All responses include a
metafield (set tonullwhen not paginated).
Paginated Response¶
{
"success": true,
"message": "...",
"meta": {
"page": 1,
"limit": 10,
"total": 100
},
"data": [...]
}
Note: The
metatype does not include atotalPagesfield. Clients must calculate it asMath.ceil(total / limit).
Error Response¶
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.tsfor 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,
roleis a string (the role name). In theGET /users/profileresponse,roleis an object{ id, name }. These are different shapes for the same concept.Note:
billingCycleinsidesubscriptionis 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):
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
refreshTokenin 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, notbackupCode.
Response data:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": 1, "email": "...", "name": "...", "role": "user", "permissions": [...], "subscription": {...} }
}
Known issue: The
refreshTokenis 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:
POST /auth/forgot-password¶
Request password reset email.
Request Body:
{
"email": "[email protected]"
}
POST /auth/reset-password¶
Reset password with token.
Request Body:
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" }
}
}
permissionsis 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 admincreateUserValidation, which meansrole,userRoles,status, andisInviteOnlyalso pass validation. The service atuser.service.tsline 533 blindly appliespayload.roletouser.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 therolefield 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:
Note: Field is
userIds, notids.
PATCH /users/bulk-restore¶
Restore multiple users from trash. Auth required.
Request Body:
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.createis commented out inrole.service.tsline 150. Any authenticated user can create roles.
Request Body:
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:
displayNameaccepted by controller but NOT in update validation schema.
Request Body:
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 /:permissionIdis defined beforeGET /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
/allname, this endpoint IS paginated — unlike other/allroutes 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 /:permissionIdroute 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:
Validation: name required (nonempty). displayName optional. readonly optional boolean.
Note: The field is
name, which maps to theroutecolumn 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:
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(notbillingCycle).
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:
Validation: packageId (integer, required), couponCode (string, optional), paymentGateway (enum: stripe|lemonsqueezy, required).
Response data:
Note: Response key is
url, notcheckoutUrl. ThepaymentGatewayfield 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:
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 inblog.service.tsexcept forPATCH /blogs/bulk-update-category. Instead, the code useshasPermission()withglobal.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 blogsThese
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-viewonly 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:
slugis NOT accepted in the request body.categoryId(singular) does not exist — usecategories(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:
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, notids.
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:
Validation: name required. description, slug, serial optional.
Known issue:
parentIdin create validation is outside thebodywrapper — 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:
originalPriceis validated as a string type, not a number. Also, there is nooriginalPricecolumn in the Product entity — onlydiscountPrice(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.
Menu Endpoints¶
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:
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.
Menu Item Endpoints¶
All routes require Auth.
Known issue: Permission strings use two naming formats — GET endpoints use
menuItem.*(camelCase) while CUD endpoints usemenu-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
itemsproperty. Required fields per item:menuId,title,link.postTypeenum: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:
permissionsfield 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:
:prefixis 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.tsis 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:
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:
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:
Webhook Endpoints¶
Important: Webhook endpoints are registered at the root level, not under
/api/v1. Mounted viaapp.use("/", webhookRouters)with/webhooksprefix.
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_EMAILandDOCS_PASSWORDenvironment 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. SetDOCS_EMAILandDOCS_PASSWORDbefore 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