Skip to main content

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:

ParamMeaningExample
pagezero-based page indexpage=0
sizepage sizesize=25
sortproperty,direction; repeatablesort=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 kindParamsValues
Text<field>Search + <field>SearchTypetype: contains (default), equals, notEqual, startsWith, endsWith, notContains, blank, notBlank
Number<field>Min + <field>Max + <field>FilterTypemin=max means equals; FilterType of greaterThan / lessThan / notEqual makes the bound strict
Date<field>From + <field>To + <field>FilterTypeISO dates (2026-07-24); same strict/notEqual/blank refinements
Enum/seta 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=true returns 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:

StatusTrigger (exception)Typical message
400Bean validation failure (MethodArgumentNotValidException)field: message, ...
400IllegalArgumentException (business-rule rejections)rule-specific, e.g. "Only pending leave requests can be approved"
400Malformed/missing JSON body"Request body is missing or malformed"
400Path/query param type mismatch"Invalid value for parameter 'id'"
400Unknown sort/filter property"Unknown sort or filter property 'x'"
401BadCredentialsException (login, refresh reuse/expiry)"Invalid username or password"
403AccessDeniedException (missing permission)"You do not have permission to access this resource"
403DisabledException (inactive account at login)"This account is inactive"
403UnauthorizedTimesheetActionException (timesheet action by wrong actor)action-specific
404ResourceNotFoundException"<Entity> not found: <id>"
409ConflictException (uniqueness, e.g. duplicate government IDs)"SSS number is already assigned to another employee"
422InvalidTimesheetStateException (timesheet state machine violation)e.g. "You have not clocked in today"
423LockedException (account lockout after repeated failures)"Account temporarily locked due to repeated failed login attempts. Try again later."
500Anything unhandledexception message

Two auth-hardening responses are written by servlet filters, before the controller advice, so their JSON has message + status but no time field:

StatusSourceBody
428AltchaVerificationFilter — missing/invalid X-Altcha-Payload on a guarded credential endpoint{"message":"Human verification failed or expired. Please try again.","status":428}
429LoginRateLimitFilter — 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.