Developer APIs
The Helpdesk module exposes two independent HTTP surfaces: an Integration (Client) REST API for external systems to read the Knowledge Base and Wiki content, and an End-User API for an authenticated employee's mobile/companion app to read Help Center articles and manage their own support tickets. Both are stateless, JWT-authenticated, tenant-aware, and return the same JSON envelope.
modules/Helpdesk/controllers/ and requires the Helpdesk module (and the relevant pillar toggle) to be enabled on the tenant. Identifiers on this page — routes, scopes, error codes, JSON fields — are taken verbatim from the controllers and route configs; do not assume fields that are not shown here.Two API surfaces at a glance
Choose the surface by who is calling and what they need to do. The Integration API authenticates as a tenant-level API client (no per-user identity); the End-User API authenticates as a single logged-in employee and enforces self-only access.
| Surface | Route prefix | Auth identity | Access | Controllers |
|---|---|---|---|---|
| Integration / Client API | api/v1/help/* |
Tenant API client (JWT), scope help:read |
read-only Knowledge Base & Wiki content | Helpdesk_api |
| End-User API | v1/api/me/* |
Authenticated employee (JWT sub) |
read Help Center + read/write own tickets | Helpdesk_euapi, Helpdesk_tickets_euapi |
Authentication & the middleware pipeline
Neither surface uses the browser session. Both are stateless: you present a JWT bearer token in the Authorization header and identify the tenant via a company key. The base controllers wire a fixed, priority-sorted middleware pipeline that runs before any controller action — resolve tenant, verify JWT, then (for the Integration API) check scope.
Tenant resolution (company_key)
ERPat is multi-tenant (one database per tenant). The tenant middleware resolves company_key from three places, in order: the X-Company-Key header, then the ?company_key= query string, then a company_key field in the JSON body. If it is missing the request fails missing_company_key; if it does not resolve to a tenant it fails unknown_company_key.
Pipeline order
The Integration API pipeline (from ApiAuthController) uses ApiTenantMiddleware, ApiJwtMiddleware and ApiRateLimitMiddleware; the End-User API pipeline (from EndUserApiAuthController) uses the EndUser* equivalents. On any failure the middleware emits the standard error envelope and exits — the controller action never runs.
Scopes vs. permits
- Integration API — scope. Inside each action the controller calls
authorizeScope('help:read'). If the bearer token does not carry that scope you get a403 forbiddenwithdetails.required_scope. - End-User API — permit. Reads of your own data need only a valid session. Mutations call
requirePermit('ticket_create'), which mirrors the webcurrent_has_permit()check; failing it returns403 forbiddenwithdetails.required_permit. The employee identity is always the JWT subject viaselfUserId()— a caller-supplied user id is never trusted.
/helpdesk_api/*, /helpdesk_euapi/*, /helpdesk_tickets_euapi/*) in addition to the documented api/v1/help/* and v1/api/me/* URLs. This is not an auth bypass — the same tenant + JWT + scope pipeline runs from the base controller regardless of which URL reached the class — but you should integrate against the documented prefixes; the bare paths are an implementation detail, not a contract.Response & error envelope
Both surfaces share one wire format. A success is {"ok": true, "data": …}; an error is {"ok": false, "error": {"code","message","details"}}. The error.code string is the stable integration contract; error.message is informational and may change. Slashes and Unicode are left unescaped.
// Success
{
"ok": true,
"data": { "...": "..." }
}
// Error
{
"ok": false,
"error": {
"code": "forbidden",
"message": "The token does not grant the required scope.",
"details": { "required_scope": "help:read" }
}
}
| HTTP | error.code | Raised by | Meaning |
|---|---|---|---|
| 400 | missing_company_key | Tenant middleware | No company_key supplied via header, query, or body. |
| 400 | unknown_company_key | Tenant middleware | The company_key does not resolve to a known tenant. |
| 401 | missing_token | JWT middleware | No Authorization: Bearer token present. |
| 401 | invalid_token | JWT middleware | Bearer token is malformed / unparseable. |
| 401 | expired_token | JWT middleware | The token has expired. |
| 401 | invalid_signature | JWT middleware | Signature verification failed. |
| 401 | revoked_token | JWT middleware | The token was revoked. |
| 401 | client_disabled | JWT middleware | The issuing API client is disabled (Integration API). |
| 403 | forbidden | authorizeScope() / requirePermit() | Token lacks the required scope, or the employee lacks the permit. |
| 404 | category_not_found | Helpdesk_api::category() | Category missing, deleted, or not active. |
| 404 | article_not_found | Helpdesk_api::article() | Article missing, deleted, or not active. |
| 404 | ticket_not_found | Ticket End-User API | Ticket does not exist or is not yours (existence hidden — never 403). |
| 405 | invalid_request | End-User API | Wrong HTTP method for the route; details.allowed lists valid methods. |
| 422 | validation_failed | All surfaces | A parameter or body field is invalid; details names the field(s). |
| 422 | invalid_id | Helpdesk_api | A required numeric id was zero or negative. |
| 500 | save_failed | Ticket End-User API | The ticket or reply could not be persisted. |
Endpoint reference
The two surfaces are documented in the tabs below. Route names in the where(['id' => '[0-9]+'])-constrained paths accept only positive integers.
Integration API — Knowledge Base & Wiki (read-only)
A tenant-scoped, read-only projection of the Help Center content for consumer apps and embedded help/KB widgets. Only active, non-deleted records are returned, and articles are always scoped to an active category. Because API clients authenticate with tenant-level credentials (no per-user category permissions), every record is resolved with access_type = 'all' — the caller sees the full public catalogue for the tenant.
api/v1/helphelp:readHelpdesk_api| Method | Path | Action | Purpose | Scope |
|---|---|---|---|---|
| GET | api/v1/help/categories | categories() | List active categories (paginated). | help:read |
| GET | api/v1/help/categories/{id} | category() | One active category by id. | help:read |
| GET | api/v1/help/articles | articles() | List active articles (paginated). | help:read |
| GET | api/v1/help/articles/{id} | article() | One active article with full HTML body. | help:read |
Query parameters
| Param | Applies to | Type / values | Notes |
|---|---|---|---|
type | categories, articles | help | knowledge_base | Filters by pillar. An unknown value fails 422 validation_failed. |
category_id | articles | positive integer | Restrict to one category. Non-numeric / < 1 fails 422 validation_failed. |
search | categories, articles | free text | Case-insensitive substring match against title + (stripped) description. |
page | categories, articles | 1-based integer | Defaults to 1; values < 1 are coerced to 1. |
per_page | categories, articles | 1–100 | Defaults to 25; capped at 100. |
Response shapes
Category objects carry: id, type, title, description, status, sort, total_articles, created_at, updated_at. Article objects carry: id, type, title, description (the full HTML body), status, total_views, a nested category (id + title), a files string array (attachment names), created_by, created_at, updated_at. List endpoints wrap the array plus a meta block (page, per_page, total).
search/status filtering in PHP and then slices the result, so meta.total reflects the count after filtering, not the raw table size.Example — list Knowledge Base articles
curl -s "https://app.erpat.example/api/v1/help/articles?type=knowledge_base&search=payroll&per_page=2" \
-H "X-Company-Key: acme" \
-H "Authorization: Bearer <JWT>"
{
"ok": true,
"data": {
"articles": [
{
"id": 42,
"type": "knowledge_base",
"title": "How payroll cut-off dates work",
"description": "<p>Payroll runs twice a month…</p>",
"status": "active",
"total_views": 318,
"category": { "id": 5, "title": "Payroll" },
"files": ["cutoff-calendar.pdf"],
"created_by": 12,
"created_at": "2026-05-02 09:14:00",
"updated_at": "2026-06-18 11:02:30"
}
],
"meta": { "page": 1, "per_page": 2, "total": 1 }
}
}
Example — a single article
curl -s "https://app.erpat.example/api/v1/help/articles/42?company_key=acme" \
-H "Authorization: Bearer <JWT>"
{
"ok": true,
"data": {
"article": {
"id": 42,
"type": "knowledge_base",
"title": "How payroll cut-off dates work",
"description": "<p>Payroll runs twice a month…</p>",
"status": "active",
"total_views": 318,
"category": { "id": 5, "title": "Payroll" },
"files": ["cutoff-calendar.pdf"],
"created_by": 12,
"created_at": "2026-05-02 09:14:00",
"updated_at": "2026-06-18 11:02:30"
}
}
}
End-User API — Help Center & my tickets
The End-User API acts as a single authenticated employee. It has two controllers: Helpdesk_euapi serves the org-wide Help Center (read), and Helpdesk_tickets_euapi serves the caller's own tickets (read + write). Every ticket read/write is bound to selfUserId() (the JWT subject); a ticket is "mine" when its requested_by or created_by equals the caller.
v1/api/meHelpdesk_euapiHelpdesk_tickets_euapiticket_create| Method | Path | Action | Purpose | Permit |
|---|---|---|---|---|
| GET | v1/api/me/help-articles | Helpdesk_euapi::index() | List active Help Center articles (org-wide). | Valid session |
| GET | v1/api/me/tickets | index() | List my own tickets (paginated, newest activity first). | Valid session |
| POST | v1/api/me/tickets | create() | Open a new ticket. | ticket_create |
| GET | v1/api/me/tickets/{id} | view() | One of my tickets + full reply thread. | Valid session (self-only) |
| POST | v1/api/me/tickets/{id}/messages | messages() | Append a reply to one of my tickets. | ticket_create |
404 ticket_not_found — never 403 — when the ticket is not yours, so the API never reveals whether another employee's ticket exists.GET /v1/api/me/help-articles
The org-wide Help Center is shared reference content, so this endpoint needs no permit beyond a valid session and is not self-scoped. Only active articles inside active, non-deleted categories are returned. Parameters: optional search (case-insensitive substring over title + body) and optional category_id (positive integer). Each item is {id, title, body, category}, where body is the article's HTML and category is the joined category title.
curl -s "https://app.erpat.example/v1/api/me/help-articles?search=leave" \
-H "X-Company-Key: acme" \
-H "Authorization: Bearer <employee-JWT>"
{
"ok": true,
"data": {
"help_articles": [
{ "id": 7, "title": "Filing a leave request", "body": "<p>Go to…</p>", "category": "Leave" }
]
}
}
GET /v1/api/me/tickets — list my tickets
Returns the caller's own tickets sorted by newest activity first (last_activity_at desc, then id). Optional filters:
| Param | Type / values | Notes |
|---|---|---|
status | free text | Case-insensitive exact match against ticket status (e.g. new, open, closed). |
ticket_type_id | positive integer | Filter by ticket category (type) id. |
priority | low|medium|high|urgent|critical | Matched through the ticket's labels CSV. Unknown values are ignored. |
overdue | 1/true/yes/on | Restrict to overdue tickets. |
start_date, end_date | YYYY-MM-DD | Inclusive created-at range; both must be strict calendar dates or the range is ignored. |
page, per_page | integers | Default page 1, per_page 25 (max 100). |
Each ticket is shaped as: id, reference (derived TKT-{id}), subject (the title), category (joined ticket type), priority (from the labels CSV, default medium), status, agent (assigned user descriptor or null; online is always false), updated_at (localized), unread (always false), and messages (the single most-recent reply, or empty). A meta block carries page/per_page/total.
GET /v1/api/me/tickets/{id} — view one ticket
Returns the same ticket shape as the list, with messages expanded to the full reply thread. Each message is {id, author, author_name, body, sent_at}, where author is "me" when the caller wrote it and "agent" otherwise. A non-owned or missing id returns 404 ticket_not_found.
POST /v1/api/me/tickets — open a ticket
Requires the ticket_create permit. The new ticket's created_by and requested_by are always the authenticated employee — never read from input — and it is created with status new. The opening message becomes the ticket's first reply.
| Body field | Required | Rules |
|---|---|---|
subject | Yes | Non-empty, max 255 characters. Stored as the ticket title. |
message | Yes | Non-empty. Becomes the ticket body and its first reply. |
category | No | Maps to ticket_type_id only when it is a numeric type id. |
priority | No | One of low/medium/high/urgent/critical (encoded into labels). Defaults to medium. Any other value fails 422. |
On success it returns 201 with {ticket_id, reference}.
curl -s -X POST "https://app.erpat.example/v1/api/me/tickets" \
-H "X-Company-Key: acme" \
-H "Authorization: Bearer <employee-JWT>" \
-H "Content-Type: application/json" \
-d '{
"subject": "Cannot access payslip portal",
"message": "I get a 403 when opening my June payslip.",
"priority": "high"
}'
{
"ok": true,
"data": {
"ticket_id": 1187,
"reference": "TKT-1187"
}
}
POST /v1/api/me/tickets/{id}/messages — reply
Requires the ticket_create permit. Ownership is verified before the body is read (so a non-owned id 404s first). The only body field is body (non-empty, required). The reply is appended to the ticket's thread and the ticket's last_activity_at is touched. Returns 201 with the created message.
curl -s -X POST "https://app.erpat.example/v1/api/me/tickets/1187/messages" \
-H "X-Company-Key: acme" \
-H "Authorization: Bearer <employee-JWT>" \
-H "Content-Type: application/json" \
-d '{ "body": "Still failing this morning — attaching a screenshot." }'
{
"ok": true,
"data": {
"message": {
"id": 5521,
"author": "me",
"author_name": "Juan Dela Cruz",
"body": "Still failing this morning — attaching a screenshot.",
"sent_at": "2026-07-02 08:41:12"
}
}
}
End-to-end workflows
1. Resolve the tenant
Send the employee's company_key as the X-Company-Key header (or ?company_key=). The tenant middleware switches the active database for the request.
2. Present the JWT
Attach Authorization: Bearer <token>. The JWT middleware verifies signature, expiry and revocation, and (End-User API) populates the employee identity from sub.
3. Call the endpoint
The controller runs its scope/permit check, executes, and returns the {"ok":true,"data":…} envelope. Handle non-2xx by reading error.code.
- On screen open, call GET
v1/api/me/tickets?status=openand render the list fromdata.tickets; showreference,subject,status,priority. - On row tap, call GET
v1/api/me/tickets/{id}and renderdata.ticket.messagesas a thread, aligningauthor === "me"to the right. - For a new request, collect
subject+message(+ optionalpriority) and POSTv1/api/me/tickets. Store the returnedreference. - For a follow-up, POST
v1/api/me/tickets/{id}/messageswith{ "body": "…" }and append the returneddata.messageoptimistically. - Surface a Knowledge Base link by calling GET
v1/api/me/help-articles?search=…so users can self-serve before opening a ticket.
- Mint (or obtain) a tenant API client token that carries the
help:readscope. - Page through GET
api/v1/help/categories?type=knowledge_baseusingmeta.totalandper_pageto build your category tree. - For each category, page GET
api/v1/help/articles?category_id={id}and cachetitle,description,files,total_views. - Re-sync on a schedule; because the API returns only
activecontent, unpublished/deleted items simply disappear from your mirror on the next pass.
OpenAPI & Postman
Machine-readable specs and Postman collections are generated by the erpat CLI. Each surface has its own generator; both accept --postman to additionally emit a Postman collection.
| Surface | Generator command | OpenAPI output |
|---|---|---|
| Integration API | php erpat api:openapi --postman | public/api/v1/openapi.json |
| End-User API | php erpat euapi:openapi --postman | public/v1/api/openapi.json |
config/api_routes.php and config/euapi_routes.php) register their routes into the same shared registrar the generators read, so regenerating after any Helpdesk route change keeps the spec in sync. Import the generated Postman collection to get pre-built requests for every endpoint on this page.Access control summary
Access to any Helpdesk API requires the module and its pillar toggle to be enabled, then the surface's own scope/permit. See Permissions Reference for the full key list.
| Capability | Surface | Requires |
|---|---|---|
| Read KB / Wiki categories & articles | Integration | Token scope help:read |
| Read org-wide Help Center articles | End-User | Valid employee session (no permit) |
| List / view my own tickets | End-User | Valid session; self-only via selfUserId() |
| Open a new ticket | End-User | Permit ticket_create |
| Reply to my ticket | End-User | Permit ticket_create |
Terminology
The tenant identifier resolved by the tenant middleware from the X-Company-Key header, ?company_key= query, or JSON body. Selects the per-tenant database for the request.
A capability claim carried inside the Integration API's JWT. The Helpdesk Integration endpoints require help:read; missing it yields 403 forbidden.
A per-employee permission checked by requirePermit(), mirroring the web current_has_permit(). Ticket writes require ticket_create.
The End-User ticket rule: every operation binds to the JWT subject (selfUserId()); a ticket is yours when requested_by or created_by matches. Non-owned reads return 404, hiding existence.
A human-facing ticket label derived as TKT-{id}. There is no reference column — it is computed from the ticket id.
Tickets have no priority column. Priority is stored as a token inside the labels CSV and read back with a default of medium.
Troubleshooting
The JWT alone does not select the tenant. Add the X-Company-Key header (or ?company_key=, or a body field). Tenant resolution runs before JWT verification in the pipeline.
Your token does not carry the help:read scope. Check error.details.required_scope and re-issue the client token with that scope.
The employee lacks the ticket_create permit (see error.details.required_permit). Reads of their own tickets do not need a permit, but opening and replying do.
The End-User API is self-only. If the ticket's requested_by/created_by is not the authenticated employee, it 404s deliberately to hide its existence — it is never surfaced across users.
You used the wrong HTTP method for the route (e.g. GET on the create path). Check error.details.allowed for the accepted methods.
Priority must be one of low, medium, high, urgent, critical; the Integration API type filter must be help or knowledge_base. The error.details object names the offending field.
Both start_date and end_date must be present and strict YYYY-MM-DD calendar dates (e.g. 2026-02-31 is rejected). If either is missing or invalid, the range filter is silently dropped.
The Helpdesk module (or the relevant pillar toggle — module_ticket / module_knowledge_base) is disabled for that tenant. A disabled module hides its surfaces to avoid leaking existence.
Related documentation
Tickets — Lifecycle
Statuses, ownership, and the full ticketing model the End-User API reads and writes.
Knowledge Base
The content the Integration API and Help Center endpoints expose.
Permissions Reference
Every permit key — including ticket_create — and how the module toggles gate access.
Glossary & Legends
Statuses, badges, and shared terminology used across the module.