DocsDevelopers
HTTP API
How the API authenticates with a session cookie and a CSRF header, the response envelope and error codes, transport limits, a worked create-an-applicant example, and what is not available yet.
HRTEQ runs a versioned HTTP API, and the web app is a client of it like any other. It is a session-authenticated internal API rather than a public platform, so before you plan an integration around it, read the availability note below.
Base URL and version#
The API is a separate service from the web app. Its origin is configured per deployment; in development it is http://localhost:4000. The web client reads it from NEXT_PUBLIC_API_URL. There is no fixed public hostname to point at.
Every route is prefixed /v1, with two exceptions: GET /health, which pings the database and returns 503 if it is unreachable, and GET /v1 itself, which returns a version banner.
The response envelope#
Every response is one of two shapes. There is no bare payload, so a client can always branch on ok before touching data.
{
"ok": true,
"data": { }
}code and fieldErrors are optional. fieldErrors appears on validation failures and maps a field name to its problems, which is enough to render inline form errors without parsing the message.
Authentication#
Sign in, then keep two cookies and echo one of them as a header on every write. That is the whole model.
POST /v1/auth/login
Send
{ email, password, surface }.surfaceis the user type —STAFF,APPLICANTorEMPLOYER— because users are unique on the pair of email and surface, so the same address can exist on more than one.Keep both cookies
hrteq_sessionis an httpOnly JWT valid for 12 hours.hrteq_csrfis readable by script and exists to be echoed back.Send X-CSRF-Token on every write
Any method other than GET, HEAD or OPTIONS must carry the
hrteq_csrfvalue in anX-CSRF-Tokenheader. The server compares the two; a mismatch is rejected. This is a double-submit CSRF defence.Use credentials: include
The API is on a different origin from the web app, so a browser client must opt in to sending cookies. Without it every call is unauthenticated.
// 1. Sign in. The response body also returns the CSRF token directly.
const login = await fetch(`${API_URL}/v1/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'ana@pacificcrest.ph',
password: '...',
surface: 'STAFF'
})
})
const { data } = await login.json()
const csrfToken = data.csrfToken
// 2. Reads need no header.
const applicants = await fetch(`${API_URL}/v1/applicants?q=santos`, {
credentials: 'include'
})
// 3. Writes must echo the CSRF token.
const created = await fetch(`${API_URL}/v1/applicants`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
firstName: 'Maria',
lastName: 'Santos',
mobile: '+639171234567',
dataPrivacyConsent: true
})
})GET /v1/auth/me returns the current user and the CSRF token, which is how a client rehydrates after a reload. POST /v1/auth/logout clears both cookies.
Authorization: Bearer <token> works and skips the CSRF check, but the only value the server accepts is the same 12-hour session JWT. Nothing mints a long-lived token, so this is a convenience for server-side calls within a session rather than an API-key mechanism.Every authenticated request re-reads the user from the database, so deactivating a user or cancelling an organization takes effect immediately rather than at token expiry.
Authorization#
Authentication tells the API who you are; the role matrix decides what you may do, and it is asserted inside the route handlers. A 403 means your role lacks the permission, and the message names the action and module — for example “Role LIAISON_OFFICER cannot create applicant.” See Roles and permissions.
Tenant isolation is enforced separately at the database layer: a query against a tenant-owned table without an organization filter throws rather than returning cross-tenant rows.
Errors#
| Status | When | Shape |
|---|---|---|
400 | Validation failure, or an upload that breaks a file rule. | error plus fieldErrors for schema failures. |
401 | Missing, invalid or expired session; wrong credentials. | error |
403 | Authenticated but not permitted — role, surface, or a suspended organization. | error |
404 | Not found, or found but outside your organization. | error |
409 | Conflict: a duplicate applicant identifier, an unavailable application, or a blocked stage gate. | error, sometimes code. |
429 | Rate limit exceeded. | error |
500 | Unexpected server error. The message is deliberately generic. | error |
503 | GET /health only, when the database is unreachable. | Health payload. |
GATE_BLOCKED#
The one error code worth handling specially. A refused stage transition returns 409 with code: "GATE_BLOCKED" and a gate object describing exactly what is unmet, so a client can list the blockers rather than showing a generic failure.
{
"ok": false,
"code": "GATE_BLOCKED",
"error": "Ticket Received Date has 1 unmet requirement(s). Ask an Owner or System Administrator if this genuinely needs an override.",
"gate": {
"targetStageCode": "TICKETING",
"targetStageName": "Ticket Received Date",
"owningRole": "LIAISON_OFFICER",
"allowed": false,
"requirements": [ ],
"unmetBlocking": [
{
"type": "PAYMENT_CLEARED",
"label": "Worker balance settled and cleared by Accounts",
"refCode": "WORKER",
"blocking": true,
"satisfied": false,
"detail": "Balance is settled but Accounts has not signed the financial clearance."
}
],
"unmetAdvisory": [ ]
}
}The detail string on each requirement is written to be shown to an officer as-is. See Stage gates.
Transport limits#
| Limit | Value |
|---|---|
| Rate limit | 300 requests per minute, keyed by client IP. |
| JSON body | 1 MiB. |
| File upload | 10 MB, one file per request, up to 20 form fields. |
| Allowed upload types | PDF, DOC, DOCX, JPEG, PNG, WEBP, HEIC. |
| Signed file URLs | 300 seconds, on object storage. |
| Session lifetime | 12 hours. |
CORS is an allowlist, not a wildcard. The API returns credentials-enabled CORS headers only for configured front-end origins, and permits the headers Content-Type, Authorization and X-CSRF-Token. An unlisted origin gets no CORS headers rather than a 500, so a browser failure there means a front-end URL misconfiguration.
Worked example: create an applicant#
The most likely integration is a form on your own website posting an applicant. The endpoint is POST /v1/applicants, and the schema is the same one the app uses.
| Field | Type | Rule |
|---|---|---|
firstName | string | Required, 1–80 characters. |
lastName | string | Required, 1–80 characters. |
dataPrivacyConsent | boolean | Required, must be literally true. |
mobile | string | Up to 40 characters. Required unless email is given. |
email | string | Must be a valid address. Required unless mobile is given. |
middleName, suffix | string | Optional, 80 and 20 characters. |
sex | enum | MALE or FEMALE. |
birthDate | string | A date, YYYY-MM-DD. |
civilStatus | enum | SINGLE, MARRIED, WIDOWED, SEPARATED or DIVORCED. |
addressLine, cityMun, province, region | string | Optional, 200 / 80 / 80 / 80 characters. |
passportNo | string | Up to 40 characters. Also used for duplicate detection. |
sourceChannel | enum | Defaults to WALK_IN. See Applicants. |
notes | string | Up to 2000 characters. |
curl -X POST "$API_URL/v1/applicants" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
--cookie jar.txt \
-d '{
"firstName": "Maria",
"lastName": "Santos",
"mobile": "+639171234567",
"email": "maria.santos@example.com",
"cityMun": "Quezon City",
"province": "Metro Manila",
"passportNo": "P1234567A",
"sourceChannel": "ONLINE_FORM",
"dataPrivacyConsent": true
}'
# 201
# { "ok": true, "data": { "id": "clx...", "applicantNo": "APP-2026-000001" } }
#
# 409 when an identifier already exists in your organization:
# { "ok": false, "error": "Maria Santos (APP-2026-000001) already has a matching identifier." }dataPrivacyConsent: true from a form asserts that the person consented. Make sure your form actually collects it and shows your privacy notice — the API records the consent timestamp and source IP on the strength of that flag. See Data privacy.Uploading a file#
Document uploads are multipart, and the metadata goes in the query string rather than the body — a consequence of streaming the file. Send documentTypeId, exactly one of applicantId or applicationId, and optionally expiresAt and documentNo. See Documents.
Not available yet#
Stated plainly so nothing here is designed around a feature that does not exist:
| Capability | Status |
|---|---|
| API keys or personal access tokens | Not built. There is no key model, issuance, scoping or rotation. Authentication is the 12-hour session cookie only. |
| Webhooks | Not built. No subscriptions, no delivery, no signing, no retry. To react to a pipeline event today, use an automation rule. |
| A published SDK | Not built. The internal packages are private and unpublished, and there is no Python client. |
| Branch or sub-organization scoping | Not built. Tenancy is the organization only. |
| Audit log export | Not built. The log is complete in the database but no endpoint serves it. See Audit trail. |
| Job order and principal writes | Read endpoints only. Job orders and positions are provisioned rather than created through the API. |
| User invitation and role assignment | Not built. See Roles and permissions. |
If you need one of these for a rollout, tell us which — email info@hrteq.com. Knowing what integrations agencies actually need is what decides the order these get built in.
