Typed Node.js client for the HaloPSA REST API. Generated directly from HaloPSA's OpenAPI 3 spec, so every section, endpoint, request param, and (where the spec provides one) response shape comes from the source.
- One axios instance per client, shared across 396 section APIs via lazy getters
- OAuth client-credentials flow with automatic token refresh
- Optional retry on transient network errors (
ECONNRESET,ETIMEDOUT,ESOCKETTIMEDOUT) - Per-operation request types from the spec; response types where the spec defines them, with conservative inference for the rest
npm install halopsaimport { HaloAPI } from 'halopsa'
const halo = new HaloAPI({
companyUrl: 'https://yourcompany.halopsa.com',
clientId: process.env.HALO_API_CLIENT_ID,
clientSecret: process.env.HALO_API_CLIENT_SECRET,
scope: 'all', // or whatever scope is granted to your application
})
const result = await halo.TicketsAPI.getTickets({ count: 10 })
console.log(result.record_count, result.tickets)
const ticket = await halo.TicketsAPI.getTicketsById({ id: 12345 })
console.log(ticket.summary)To use this library you need an Application registered in HaloPSA configured for the Client Credentials grant.
In the Halo UI: Config -> Integrations -> Halo PSA API -> Applications -> New. Set:
- Authentication Method:
Client ID and Secret - Login Type:
Agent - Agent to log in as: a real, active agent account
- Permissions: pick a scope (
all,all:standard, etc.) that covers the endpoints you will call
Copy the Client ID and Client Secret into your env. The Client Secret is shown once; rotate from the same screen if you lose it.
If /auth/token returns a 500 with an HTML error page, the most common cause is a missing or disabled "Agent to log in as".
new HaloAPI({
companyUrl, // required: e.g. "https://yourcompany.halopsa.com" (no trailing slash, no /api)
clientId, // required
clientSecret, // required
scope, // optional, default "all"; must match a scope granted to the Application
timeout, // optional, axios request timeout in ms (default 20000)
retry, // optional boolean, default false; retries on transient network errors
retryOptions, // optional, see below
logger, // optional (level, message, meta?) => void
debug, // optional boolean; also reads HALO_PSA_DEBUG env var
})retryOptions is forwarded to promise-retry:
{
retries: 4, // attempts after the first failure
minTimeout: 50, // ms
maxTimeout: 20000, // ms
randomize: true,
factor: 2, // optional exponential backoff factor
}The OpenAPI spec groups every endpoint by its first URL segment. Each group becomes a section client exposed on HaloAPI as a property named <Section>API. Method names within a section follow <verb><PathSegments>:
| URL | Method |
|---|---|
GET /Tickets |
halo.TicketsAPI.getTickets({...}) |
GET /Tickets/{id} |
halo.TicketsAPI.getTicketsById({ id }) |
POST /Tickets |
halo.TicketsAPI.postTickets({...}) |
DELETE /Tickets/{id} |
halo.TicketsAPI.deleteTicketsById({ id }) |
GET /Actions |
halo.ActionsAPI.getActions({...}) |
GET /Actions/{id} |
halo.ActionsAPI.getActionsById({ id }) |
POST /Actions/Review |
halo.ActionsAPI.postActionsReview({...}) |
Section clients are lazy: halo.TicketsAPI instantiates the section the first time you touch it and caches it on the instance. You only pay for sections you use, but they all share one axios client and one OAuth token.
Every method takes a single object argument containing path params, query params, and (for write methods) the request body — keeps call sites readable when an endpoint has 30+ optional flags.
HaloPSA's spec defines response types for ~6% of operations directly. For the rest, the library infers a return type from a per-section schema hint when an obvious pattern matches:
| Pattern | Inferred response |
|---|---|
GET /<Section> |
<Section>_View (Halo's list-wrapper schema) |
GET /<Section>/{id} |
<Section> (single resource) |
POST /<Section> |
<Section> (created resource) |
PUT/PATCH /<Section>/{id} |
<Section> (updated resource) |
| Anything else | unknown |
Hints come from two sources, in order:
- Operations elsewhere in the same section that do have spec-typed responses.
- Schema name match: if the spec defines
components.schemas.<Section>or<Section>_View, those are used.
When neither produces a hint, the return type is unknown. Cast or narrow at the call site if you have out-of-band knowledge of the shape.
Attachments are uploaded as an array of Attachment objects with the file contents base64-encoded in the data field. The server returns the created record, including an id you can use for downloads.
import fs from 'node:fs'
const bytes = fs.readFileSync('report.pdf')
const created = await halo.AttachmentAPI.postAttachment({
attachmentList: [{
ticket_id: 123,
filename: 'report.pdf',
data: bytes.toString('base64'),
}],
})
console.log(`uploaded id=${created.id}, size=${created.filesize}`)Download the raw bytes back with getAttachmentById. The return type is a Node Buffer, ready to write to disk or pipe wherever you need.
const buf = await halo.AttachmentAPI.getAttachmentById({ id: created.id })
fs.writeFileSync('report.pdf', buf)POST /Attachment/document accepts the same body shape and behaves identically to POST /Attachment. Use whichever you prefer.
The /Attachment/image endpoint is a separate staging flow for rich-text editor inline images. It returns a token URL rather than persisting an attachment to a ticket, and the spec declares ~100 query-string parameters that most callers will not need. The generated method is included for completeness but most library users should reach for postAttachment instead.
Every section file re-exports the schemas it references. Import them by name:
import type { Faults, Faults_View } from 'halopsa/dist/HaloPSA/TicketsAPI'
function summarize(view: Faults_View) {
return view.tickets?.map((t) => `${t.id}: ${t.summary}`)
}The full set lives in dist/HaloPSATypes.ts, generated by openapi-typescript. You can also reach any schema by name through the components map:
import type { components } from 'halopsa/dist/types'
type Ticket = components['schemas']['Faults']Errors are thrown as a plain object, not an axios error:
try {
await halo.TicketsAPI.getTicketsById({ id: 999999 })
} catch (err) {
// err.status number | string (HTTP status)
// err.data response body (parsed JSON if Halo returned JSON)
// err.message axios error message
}Network-level failures that don't have an HTTP response (DNS, ECONNREFUSED, etc.) are re-thrown as the underlying error.
The default logger writes error to console.error always; warn and info only when debug: true is passed (or HALO_PSA_DEBUG env is set). Pass your own to integrate with pino, winston, etc.:
const halo = new HaloAPI({
// ...
logger: (level, message, meta) => log[level]({ msg: message, ...meta }),
})Disabled by default. Pass retry: true to retry only on connection-level errors (ECONNRESET, ETIMEDOUT, ESOCKETTIMEDOUT). HTTP error responses (4xx, 5xx) are not retried; they throw immediately.
const halo = new HaloAPI({
// ...
retry: true,
retryOptions: { retries: 6, minTimeout: 100, maxTimeout: 5000 },
})- Some operations have parameter names with dots (e.g.
file_stream.CanReadon/Attachment/image). These are aliased to safe identifiers in the TypeScript signature and re-mapped to their original key on the wire, but you should mostly avoid them. - Halo permission scopes vary by tenant. Even with
scope: 'all', individual endpoints may return 401 if the linked agent lacks permission. halopsa.HaloPSATypesis large (~1.4 MB on disk). Treat it as a type-only import; tree-shaking won't help if you accidentallyrequire()it at runtime.
The library is regenerated from generator/spec/halo-psa-api.json. To refresh after Halo updates the spec:
npm run generateThis wipes src/HaloPSA/, src/HaloAPI.ts, and src/HaloPSATypes.ts, then re-emits all section files, the barrel, and the schema types.
MIT.