Skip to content
HRTEQDocs
Documentation API

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.

There is no public API product yet. No API keys, no personal access tokens, no webhooks, no published SDK. Every call is authenticated with the same short-lived session cookie a browser gets, which means machine-to-machine integration is not something you can set up today. The endpoint surface is also not contract-stable — it exists to serve the app and will change when a real public API ships.

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.

ApiSuccess
{
  "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.

  1. POST /v1/auth/login

    Send { email, password, surface }. surface is the user type — STAFF, APPLICANT or EMPLOYER — because users are unique on the pair of email and surface, so the same address can exist on more than one.

  2. Keep both cookies

    hrteq_session is an httpOnly JWT valid for 12 hours. hrteq_csrf is readable by script and exists to be echoed back.

  3. Send X-CSRF-Token on every write

    Any method other than GET, HEAD or OPTIONS must carry the hrteq_csrf value in an X-CSRF-Token header. The server compares the two; a mismatch is rejected. This is a double-submit CSRF defence.

  4. 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.

sign-in.ts
// 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.

A bearer token is accepted, but it is the same session token. Sending 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#

StatusWhenShape
400Validation failure, or an upload that breaks a file rule.error plus fieldErrors for schema failures.
401Missing, invalid or expired session; wrong credentials.error
403Authenticated but not permitted — role, surface, or a suspended organization.error
404Not found, or found but outside your organization.error
409Conflict: a duplicate applicant identifier, an unavailable application, or a blocked stage gate.error, sometimes code.
429Rate limit exceeded.error
500Unexpected server error. The message is deliberately generic.error
503GET /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.

409 GATE_BLOCKED
{
  "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#

LimitValue
Rate limit300 requests per minute, keyed by client IP.
JSON body1 MiB.
File upload10 MB, one file per request, up to 20 form fields.
Allowed upload typesPDF, DOC, DOCX, JPEG, PNG, WEBP, HEIC.
Signed file URLs300 seconds, on object storage.
Session lifetime12 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.

FieldTypeRule
firstNamestringRequired, 1–80 characters.
lastNamestringRequired, 1–80 characters.
dataPrivacyConsentbooleanRequired, must be literally true.
mobilestringUp to 40 characters. Required unless email is given.
emailstringMust be a valid address. Required unless mobile is given.
middleName, suffixstringOptional, 80 and 20 characters.
sexenumMALE or FEMALE.
birthDatestringA date, YYYY-MM-DD.
civilStatusenumSINGLE, MARRIED, WIDOWED, SEPARATED or DIVORCED.
addressLine, cityMun, province, regionstringOptional, 200 / 80 / 80 / 80 characters.
passportNostringUp to 40 characters. Also used for duplicate detection.
sourceChannelenumDefaults to WALK_IN. See Applicants.
notesstringUp to 2000 characters.
Terminal
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." }
Consent has to be real. Sending 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:

CapabilityStatus
API keys or personal access tokensNot built. There is no key model, issuance, scoping or rotation. Authentication is the 12-hour session cookie only.
WebhooksNot built. No subscriptions, no delivery, no signing, no retry. To react to a pipeline event today, use an automation rule.
A published SDKNot built. The internal packages are private and unpublished, and there is no Python client.
Branch or sub-organization scopingNot built. Tenancy is the organization only.
Audit log exportNot built. The log is complete in the database but no endpoint serves it. See Audit trail.
Job order and principal writesRead endpoints only. Job orders and positions are provisioned rather than created through the API.
User invitation and role assignmentNot 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.

Was this page helpful?