# HaloPSA Webhooks

Halo (HaloPSA, HaloITSM, HaloCRM) sends a JSON webhook when a notification event fires, such as
a ticket being logged or reassigned. Webhooks are **not signed**. The only
authentication option is **Basic Authentication**: Halo sends
`Authorization: Basic base64(username:password)` and your endpoint checks it against the
username and password you entered on the webhook.

**Choose Basic Authentication on the Halo webhook.**

## When to Use This Skill

* How do I receive HaloPSA webhooks?
* How do I authenticate Halo webhooks with Basic auth?
* Does HaloPSA sign webhook payloads? (No: Basic Authentication is the only auth option)
* How do I handle `New Ticket Logged` or `Ticket Reassigned` events?
* How do I handle batched Halo webhooks (a JSON array)?
* Why does my Halo webhook endpoint return 401?

## Verification (core)

```javascript
const crypto = require('crypto');

const digest = (v) => crypto.createHash('sha256').update(v).digest();
const safeEqual = (a, b) => crypto.timingSafeEqual(digest(a), digest(b)); // equal-length buffers

function verifyHaloPsaRequest(headers, env = process.env) {
  const m = /^basic +([A-Za-z0-9+/]+={0,2})$/i.exec(headers['authorization'] || '');
  if (!m || m[1].length % 4 || !env.HALO_PSA_WEBHOOK_USERNAME || !env.HALO_PSA_WEBHOOK_PASSWORD) return false;
  let decoded;
  try { decoded = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(m[1], 'base64')); }
  catch { return false; }
  const i = decoded.indexOf(':');                  // split on the FIRST colon
  if (i === -1) return false;
  const userOk = safeEqual(decoded.slice(0, i), env.HALO_PSA_WEBHOOK_USERNAME);
  const passOk = safeEqual(decoded.slice(i + 1), env.HALO_PSA_WEBHOOK_PASSWORD);
  return userOk && passOk;                         // both evaluated, no short-circuit
}

```

* Header: `Authorization: Basic base64(username:password)`. The scheme is case-insensitive. Accept
  only the strict base64 alphabet and decode strictly (`validate=True` in Python) so valid
  credentials followed by junk are rejected.
* Compare the username and the password **each** in constant time (Node: SHA-256 digests or
  equal-length Buffers with `crypto.timingSafeEqual`; Python: `hmac.compare_digest` on **bytes**,
  never `str`, which raises on non-ASCII) and evaluate both before deciding.
* Missing or malformed header, or a mismatch: `401`. A body that isn't a JSON object (or array of
  objects): `400`. Handled: `200`.
* The credentials must arrive on the **first** request. The Halo docs don't say whether Basic
  credentials are sent preemptively, and Hookdeck never sends a `401` + `WWW-Authenticate`
  challenge: it verifies the request as received.
* A static shared credential authenticates the sender, **not the body**, and gives **no replay
  protection** (no signature, no timestamp). Use HTTPS and rotate the credential if it leaks.
* Read the body raw (`express.raw({ type: '*/*' })`, so any content type works) and
  authenticate before parsing it.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/halo-psa-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/halo-psa-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/halo-psa-webhooks/examples/fastapi/).

## Event Types

Halo webhooks fire on the same events as Halo notification profiles. You pick at least one
event per webhook, and can put conditions on them.

| Event | Sent When |
| --- | --- |
| `New Ticket Logged` | A ticket is created |
| `Ticket Reassigned` | A ticket is reassigned |
| `Document deleted on a Ticket` | An attachment is deleted from a ticket (v2.242+) |
| `Document edited on a Ticket` | An attachment is edited on a ticket (v2.242+) |

Every payload carries an ID, notification ID, escmsg ID, event name, webhook ID, object ID and
timestamp. The docs don't name those JSON properties in text, so the example payloads here are
**illustrative**: inspect a real delivery. With **batching** on, the body is a JSON array of
objects instead of one object. The **Method** setting also offers GET (for retrieving data), but
notifications are POSTs and Hookdeck's HaloPSA source accepts POST only.

## Environment Variables

```bash
HALO_PSA_WEBHOOK_USERNAME=halo-webhook                   # entered on the Halo webhook
HALO_PSA_WEBHOOK_PASSWORD=replace-with-a-long-random-password

```

## Local Development

Halo can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 halo-psa --path /webhooks/halo-psa

```

Use the printed URL as the webhook's **Payload URL** in Halo. Hookdeck's source type is
`HALO_PSA`, labelled "HaloPSA".

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/halo-psa-webhooks/references/overview.md) - What Halo webhooks are, events, payload and batching
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/halo-psa-webhooks/references/setup.md) - Create the webhook with Basic Authentication
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/halo-psa-webhooks/references/verification.md) - Basic auth check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/halo-psa-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests