API Conventions
Cross-cutting behavior shared by (nearly) every endpoint. Per-module endpoint tables live in the other files in this directory; the exhaustive field-level contract is Swagger UI (README.md).
URL prefix and authentication header
-
Every REST endpoint lives under
/api. There is no version segment. -
Authenticated endpoints require an access token from
POST /api/auth/login:Authorization: Bearer <accessToken> -
The only endpoints that skip the JWT are the public ones (
/api/public/**,/api/auth/*,/api/portal/auth/*,/api/webhooks/*) — see ../security/authentication.md for the full public list, and billing-and-webhooks.md for how webhooks authenticate by signature instead.
Pagination
List endpoints use Spring Data Pageable request params:
| Param | Meaning | Example |
|---|---|---|
page | zero-based page index | page=0 |
size | page size | size=25 |
sort | property,direction; repeatable | sort=lastName,asc&sort=firstName,asc |
The response is not the raw Spring Page — most list endpoints wrap it in
PageResponseDto
(backend/src/main/java/com/motorph/payroll/dto/PageResponseDto.java):
{
"content": [ { "...": "row DTOs" } ],
"totalElements": 34,
"totalPages": 2,
"currentPage": 0,
"pageSize": 25
}
currentPage is zero-based. A handful of endpoints return other shapes — the
portal order list returns the raw Spring Page JSON, and small lookup
endpoints (leave types, deduction types, rate tables) return plain JSON arrays.
Check Swagger for the endpoint you're calling.
Sorting by an unknown property is a 400: PropertyReferenceException is mapped
to "Unknown sort or filter property '<name>'".
Dynamic filtering (the AG Grid convention)
Grid-backed list endpoints accept a flat set of optional query params — one
family per filterable column — that the backend assembles into JPA
Specifications. The naming convention, using GET /api/employees as the
reference example
(EmployeeController.java):
| Column kind | Params | Values |
|---|---|---|
| Text | <field>Search + <field>SearchType | type: contains (default), equals, notEqual, startsWith, endsWith, notContains, blank, notBlank |
| Number | <field>Min + <field>Max + <field>FilterType | min=max means equals; FilterType of greaterThan / lessThan / notEqual makes the bound strict |
| Date | <field>From + <field>To + <field>FilterType | ISO dates (2026-07-24); same strict/notEqual/blank refinements |
| Enum/set | a dedicated param (status, departmentId, …) | exact match |
The frontend side of this contract is
frontend/src/ui/ag-grid/filterUtils.ts
(extractTextFilter / extractNumberRange / extractDateRange translate the
AG Grid filter model into these params) and the per-module API modules such as
frontend/src/api/employees.ts, whose
ListEmployeesParams mirrors the controller's @RequestParam list
one-for-one. The backend match logic lives in repository/specification/
classes, e.g.
EmployeeSpecifications.java,
which also escapes %/_ so filter text can't inject LIKE wildcards.
Two extra grid conveniences on the bigger endpoints:
fields=— comma-separated column list; the response rows become flat maps holding only those columns (the enterprise grid sends its visible columns, so hiding a column narrows the query).archived=—truereturns only archived rows; omitted returns only live rows (see soft delete in hr.md).
Validation
Request bodies are validated with @Valid + Jakarta Bean Validation
annotations on the request DTOs. A failed validation returns 400 with the
standard error envelope; the message concatenates every field error as
field: message, comma-separated
(GlobalControllerAdvice.handleValidation):
{
"message": "lastName: Last name is required, basicSalary: Basic salary is required",
"status": 400,
"time": "2026-07-24T14:03:22.1234567"
}
There is no structured per-field errors array — clients that need field-level
mapping parse the field: prefixes (the frontend forms do their own Zod
validation before submitting, so this is a backstop).
Error responses
All errors thrown from controllers/services are normalized by
GlobalControllerAdvice
into one envelope, ApiExceptionResponse:
{ "message": "Employee not found: 999", "status": 404, "time": "2026-07-24T14:03:22" }
time is Asia/Manila local time. The full table:
| Status | Trigger (exception) | Typical message |
|---|---|---|
| 400 | Bean validation failure (MethodArgumentNotValidException) | field: message, ... |
| 400 | IllegalArgumentException (business-rule rejections) | rule-specific, e.g. "Only pending leave requests can be approved" |
| 400 | Malformed/missing JSON body | "Request body is missing or malformed" |
| 400 | Path/query param type mismatch | "Invalid value for parameter 'id'" |
| 400 | Unknown sort/filter property | "Unknown sort or filter property 'x'" |
| 401 | BadCredentialsException (login, refresh reuse/expiry) | "Invalid username or password" |
| 403 | AccessDeniedException (missing permission) | "You do not have permission to access this resource" |
| 403 | DisabledException (inactive account at login) | "This account is inactive" |
| 403 | UnauthorizedTimesheetActionException (timesheet action by wrong actor) | action-specific |
| 404 | ResourceNotFoundException | "<Entity> not found: <id>" |
| 409 | ConflictException (uniqueness, e.g. duplicate government IDs) | "SSS number is already assigned to another employee" |
| 422 | InvalidTimesheetStateException (timesheet state machine violation) | e.g. "You have not clocked in today" |
| 423 | LockedException (account lockout after repeated failures) | "Account temporarily locked due to repeated failed login attempts. Try again later." |
| 500 | Anything unhandled | exception message |
Two auth-hardening responses are written by servlet filters, before the
controller advice, so their JSON has message + status but no time
field:
| Status | Source | Body |
|---|---|---|
| 428 | AltchaVerificationFilter — missing/invalid X-Altcha-Payload on a guarded credential endpoint | {"message":"Human verification failed or expired. Please try again.","status":428} |
| 429 | LoginRateLimitFilter — more than 10 POSTs/minute per client IP to a login/refresh/register path | {"message":"Too many attempts. Please wait a minute and try again.","status":429} |
See auth.md for when 428/429 apply.
Permissions in one paragraph
Authorization is method-level @PreAuthorize("hasAuthority('...')") where the
authorities are permission names, not roles — e.g. hr.employees.create,
payroll.manage, system.admin.users.view. The canonical list is
PermissionConstants.java,
mirroring the seeded RBAC permission table; a user's effective authorities
are the union of the permissions of all their roles (the frontend's "active
role" switcher is cosmetic — the backend always checks the union). Every
endpoint table in these docs lists the exact permission string(s) verified
against the controller source. Token internals, refresh rotation, lockout, and
the RBAC data model: ../security/authentication.md.