Helpdesk Reference Public

Developer APIs

The Integration REST API (api/v1/help, read-only, help:read scope) and the End-User API (v1/api/me/tickets read+write, v1/api/me/help-articles read).

Guide version: r1 Module version: 0.1.0 Updated: 2026-07-22 Estimated time: 15 min 2 views

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.

Note Every endpoint on this page is served by a flat, module-owned controller under 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.
Integration Developer Mobile / App Developer Systems Integrator Employee (via app)

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.

SurfaceRoute prefixAuth identityAccessControllers
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
Rule of thumb Building a public help widget, a docs portal, or a backend integration that mirrors your KB? Use the Integration API. Building a "My Support" screen inside an employee-facing app where the user opens and replies to their own tickets? Use the End-User API.

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

SecureHeaders1
Maintenance3
CORS5
Tenant10
JWT20
RateLimit25

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 a 403 forbidden with details.required_scope.
  • End-User API — permit. Reads of your own data need only a valid session. Mutations call requirePermit('ticket_create'), which mirrors the web current_has_permit() check; failing it returns 403 forbidden with details.required_permit. The employee identity is always the JWT subject via selfUserId() — a caller-supplied user id is never trusted.
Known quirk — second entry point Because module API classes must be flat top-level controllers, each is also reachable by its bare class path (/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" }
  }
}
HTTPerror.codeRaised byMeaning
400missing_company_keyTenant middlewareNo company_key supplied via header, query, or body.
400unknown_company_keyTenant middlewareThe company_key does not resolve to a known tenant.
401missing_tokenJWT middlewareNo Authorization: Bearer token present.
401invalid_tokenJWT middlewareBearer token is malformed / unparseable.
401expired_tokenJWT middlewareThe token has expired.
401invalid_signatureJWT middlewareSignature verification failed.
401revoked_tokenJWT middlewareThe token was revoked.
401client_disabledJWT middlewareThe issuing API client is disabled (Integration API).
403forbiddenauthorizeScope() / requirePermit()Token lacks the required scope, or the employee lacks the permit.
404category_not_foundHelpdesk_api::category()Category missing, deleted, or not active.
404article_not_foundHelpdesk_api::article()Article missing, deleted, or not active.
404ticket_not_foundTicket End-User APITicket does not exist or is not yours (existence hidden — never 403).
405invalid_requestEnd-User APIWrong HTTP method for the route; details.allowed lists valid methods.
422validation_failedAll surfacesA parameter or body field is invalid; details names the field(s).
422invalid_idHelpdesk_apiA required numeric id was zero or negative.
500save_failedTicket End-User APIThe 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.

Prefix
api/v1/help
Scope
help:read
Methods
GET only
Controller
Helpdesk_api
Default per_page
25
Max per_page
100
MethodPathActionPurposeScope
GETapi/v1/help/categoriescategories()List active categories (paginated).help:read
GETapi/v1/help/categories/{id}category()One active category by id.help:read
GETapi/v1/help/articlesarticles()List active articles (paginated).help:read
GETapi/v1/help/articles/{id}article()One active article with full HTML body.help:read

Query parameters

ParamApplies toType / valuesNotes
typecategories, articleshelp | knowledge_baseFilters by pillar. An unknown value fails 422 validation_failed.
category_idarticlespositive integerRestrict to one category. Non-numeric / < 1 fails 422 validation_failed.
searchcategories, articlesfree textCase-insensitive substring match against title + (stripped) description.
pagecategories, articles1-based integerDefaults to 1; values < 1 are coerced to 1.
per_pagecategories, articles1–100Defaults 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).

Pagination detail The Integration API applies 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.

Prefix
v1/api/me
Help Center controller
Helpdesk_euapi
Tickets controller
Helpdesk_tickets_euapi
Write permit
ticket_create
Default per_page
25
Max per_page
100
MethodPathActionPurposePermit
GETv1/api/me/help-articlesHelpdesk_euapi::index()List active Help Center articles (org-wide).Valid session
GETv1/api/me/ticketsindex()List my own tickets (paginated, newest activity first).Valid session
POSTv1/api/me/ticketscreate()Open a new ticket.ticket_create
GETv1/api/me/tickets/{id}view()One of my tickets + full reply thread.Valid session (self-only)
POSTv1/api/me/tickets/{id}/messagesmessages()Append a reply to one of my tickets.ticket_create
Self-only, existence hidden Single-ticket reads and writes assert ownership and return 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:

ParamType / valuesNotes
statusfree textCase-insensitive exact match against ticket status (e.g. new, open, closed).
ticket_type_idpositive integerFilter by ticket category (type) id.
prioritylow|medium|high|urgent|criticalMatched through the ticket's labels CSV. Unknown values are ignored.
overdue1/true/yes/onRestrict to overdue tickets.
start_date, end_dateYYYY-MM-DDInclusive created-at range; both must be strict calendar dates or the range is ignored.
page, per_pageintegersDefault 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 fieldRequiredRules
subjectYesNon-empty, max 255 characters. Stored as the ticket title.
messageYesNon-empty. Becomes the ticket body and its first reply.
categoryNoMaps to ticket_type_id only when it is a numeric type id.
priorityNoOne 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.

SOP — Build a "My Support" screen (mobile app)
  1. On screen open, call GET v1/api/me/tickets?status=open and render the list from data.tickets; show reference, subject, status, priority.
  2. On row tap, call GET v1/api/me/tickets/{id} and render data.ticket.messages as a thread, aligning author === "me" to the right.
  3. For a new request, collect subject + message (+ optional priority) and POST v1/api/me/tickets. Store the returned reference.
  4. For a follow-up, POST v1/api/me/tickets/{id}/messages with { "body": "…" } and append the returned data.message optimistically.
  5. Surface a Knowledge Base link by calling GET v1/api/me/help-articles?search=… so users can self-serve before opening a ticket.
SOP — Mirror the Knowledge Base into an external portal
  1. Mint (or obtain) a tenant API client token that carries the help:read scope.
  2. Page through GET api/v1/help/categories?type=knowledge_base using meta.total and per_page to build your category tree.
  3. For each category, page GET api/v1/help/articles?category_id={id} and cache title, description, files, total_views.
  4. Re-sync on a schedule; because the API returns only active content, 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.

SurfaceGenerator commandOpenAPI output
Integration APIphp erpat api:openapi --postmanpublic/api/v1/openapi.json
End-User APIphp erpat euapi:openapi --postmanpublic/v1/api/openapi.json
Tip The Helpdesk route fragments (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.

CapabilitySurfaceRequires
Read KB / Wiki categories & articlesIntegrationToken scope help:read
Read org-wide Help Center articlesEnd-UserValid employee session (no permit)
List / view my own ticketsEnd-UserValid session; self-only via selfUserId()
Open a new ticketEnd-UserPermit ticket_create
Reply to my ticketEnd-UserPermit ticket_create

Terminology

company_key tenant

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.

scope integration

A capability claim carried inside the Integration API's JWT. The Helpdesk Integration endpoints require help:read; missing it yields 403 forbidden.

permit end-user

A per-employee permission checked by requirePermit(), mirroring the web current_has_permit(). Ticket writes require ticket_create.

self-only

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.

reference

A human-facing ticket label derived as TKT-{id}. There is no reference column — it is computed from the ticket id.

priority (via labels)

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

Was this guide helpful?

Report a content problem