# Guide to HaloPSA Webhooks: Features and Best Practices

HaloPSA webhooks push service desk activity out of Halo: a ticket is logged or reassigned, or an attachment on a ticket changes, and Halo sends the details to a URL you choose. If you run an MSP or an internal IT team on HaloPSA, HaloITSM or HaloCRM, webhooks are how ticket events reach your billing, alerting, reporting or automation tools without polling the Halo API.

This guide covers how Halo webhooks are configured, how to authenticate them with Basic Authentication, the payload types and batching options, and the practices that make a Halo receiver dependable. Halo's documentation now lives on usehalo.com, and the halopsa.com guides redirect there. The guide applies to HaloPSA, HaloITSM and HaloCRM alike, because they share one webhook feature.

## What are HaloPSA webhooks?

A Halo webhook is a profile with a payload URL, an authentication setting, a payload type and a list of events. Halo describes the events as "basically the same as Halo Notification profiles", for example **New Ticket Logged** or **Ticket Reassigned**, and you can put conditions on them as you would on a notification. When an event's conditions are met, Halo queues an HTTP `POST` to the payload URL with a JSON body, and records your endpoint's response on the webhook's **Deliveries** tab.

Halo doesn't sign webhooks. The authentication options are **No Authentication** and **Basic Authentication**, which takes a username and password. With Basic Authentication, your receiver checks the `Authorization: Basic ...` header against the pair you entered.

## HaloPSA webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Configuration > Integrations > Webhooks > New** |
| Webhook type | **Standard webhook**, unless you're setting up a pre-configured integration |
| Method | `POST` to send data out of Halo. `GET` is for webhooks that retrieve data |
| Format | JSON |
| Authentication | No Authentication, or Basic Authentication. No signature |
| Payload types | Small object with key fields, full object with linked objects, event information only, or a custom payload |
| Events | Halo notification events, with conditions. At least one per webhook |
| Batching | Optional. Turns the body into a JSON array |
| Delivery log | The webhook's **Deliveries** tab, with re-send from logs |
| Retries and timeout | Not documented |
| Source IPs | Not published on the webhook guide |

## Common events

Halo doesn't publish a fixed list of webhook events. The events are the ones you'd use in a notification profile, chosen in the webhook's event list. Halo names these examples:

| Event | Fires 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 and later) |
| Document edited on a Ticket | An attachment on a ticket is edited (v2.242 and later) |

Every payload type contains "an ID, notification ID, escmsg ID, event name, webhook ID, object ID, and timestamp". What else it carries depends on the payload type:

* **Small object with key fields only** includes the basic properties of the entity.
* **Full object with linked objects** includes everything relevant.
* **Event information only** includes the object ID and event name.
* **Custom payload** lets you choose the fields. For single-select fields, a setting chooses whether the database ID or the display value is sent.

Halo's example payload is a screenshot, so the guide doesn't give the JSON keys for those common properties as text. Send a test event and read the exact keys from your first delivery before you write routing code.

## Setting up HaloPSA webhooks

1. Choose a username and generate a long random password, for example with `openssl rand -hex 32`.
2. In Halo, go to **Configuration > Integrations > Webhooks** and click **New**.
3. Enter a **Webhook Name** and your HTTPS endpoint as the **Payload URL**.
4. Set **Webhook Type** to **Standard webhook** and **Method** to **POST**.
5. Set **Authentication** to **Basic Authentication** and enter the username and password.
6. Choose a **Payload** type. Start with small objects unless you need everything.
7. Add at least one event, with conditions if you need them.
8. Leave **Batching** on "One delivery for each occurrence of an event" until your receiver handles arrays.
9. Save, trigger the event, and check the **Deliveries** tab.

**In Hookdeck.** Create a source with the HaloPSA type, enter the same **Username** and **Password**, and use the source URL as the Payload URL. Choose Basic Authentication on the Halo webhook. The source accepts `POST` only, which matches Halo's outbound webhooks. Hookdeck never answers with a `401` challenge, so the credentials have to arrive with each request. Halo's guide doesn't say how it sends Basic credentials, so confirm on your first delivery that the request in Hookdeck carries an `Authorization: Basic` header. If you choose No Authentication in Halo, leave verification off on the source, though a credential is the better choice.

For local development, `hookdeck listen 3000 halo-psa --path /webhooks/halo-psa` with the [Hookdeck CLI](/docs/cli) gives you a public HTTPS URL, with no account required.

## Securing HaloPSA webhooks

Parse the `Authorization` header, decode the base64 credentials, split on the first colon, and compare the username and password in constant time. Evaluate both comparisons before deciding.

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

const app = express();

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyHaloRequest(header) {
  const user = process.env.HALO_WEBHOOK_USERNAME;
  const pass = process.env.HALO_WEBHOOK_PASSWORD;
  if (!user || !pass) return false; // fail closed when unconfigured

  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64').toString('utf8');
  const sep = decoded.indexOf(':'); // the password may contain colons
  if (sep === -1) return false;

  const userOk = safeEqual(decoded.slice(0, sep), user);
  const passOk = safeEqual(decoded.slice(sep + 1), pass);
  return userOk && passOk;
}

app.post('/webhooks/halo-psa', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyHaloRequest(req.get('authorization'))) return res.sendStatus(401);

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  const items = Array.isArray(body) ? body : [body]; // batching sends an array

  // Queue the items for a worker, then acknowledge.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os
import re

def verify_halo_request(header) -> bool:
    user = os.environ.get("HALO_WEBHOOK_USERNAME")
    password = os.environ.get("HALO_WEBHOOK_PASSWORD")
    if not user or not password:
        return False  # fail closed
    match = re.match(r"^basic\s+([A-Za-z0-9+/]+={0,2})$", header or "", re.IGNORECASE)
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True).decode("utf-8")
    except (binascii.Error, UnicodeDecodeError):
        return False
    if ":" not in decoded:
        return False
    got_user, got_password = decoded.split(":", 1)
    user_ok = hmac.compare_digest(got_user.encode("utf-8"), user.encode("utf-8"))
    password_ok = hmac.compare_digest(got_password.encode("utf-8"), password.encode("utf-8"))
    return user_ok and password_ok

```

Basic credentials prove the sender knows the pair. They don't prove the body is unchanged, and a captured request can be replayed, since there's no signature or timestamp. Use HTTPS, a long random password, and change it in Halo and your receiver together if it may have leaked.

> **Make HaloPSA webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credentials on every Halo delivery, stores each event durably, and retries delivery to your service on a schedule you control.

## HaloPSA webhook limitations and pain points

### The payload keys aren't documented in text

**The Problem:** You can't write a parser for the common fields from the documentation alone.

**Why It Happens:** Halo's guide names the common properties in prose and shows the example payload and response only as screenshots. The event names are display labels, so check the exact string your instance sends.

**Workarounds:**

* Trigger each event you care about and record the real payload before writing handlers.
* Prefer small objects or a custom payload, so the body stays predictable.

**How Hookdeck Can Help:** Hookdeck stores every request with its headers and body, so your first test delivery becomes a reference payload you can search, compare and replay against your handler.

### Batching changes the body's shape

**The Problem:** A receiver written for single objects breaks the day someone turns on batching.

**Why It Happens:** With batching on, "the webhook's payload will be a JSON array of objects rather than a single JSON object". Batching is only available for standard webhooks using `POST` and not with full-object payloads.

**Workarounds:**

* Accept both shapes from the start: wrap a single object in an array.
* Set **Maximum items per batch** so batches stay a size your receiver can process quickly.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/transformations) can normalise both shapes into one before delivery, so your service always sees the same structure.

### Retries aren't documented

**The Problem:** You can't tell how long Halo keeps trying if your endpoint is down.

**Why It Happens:** Halo's guide covers the Deliveries tab and re-sending from logs, but not a retry schedule, a timeout or which status codes count as success.

**Workarounds:**

* Respond with a `2xx` quickly and do the work afterwards.
* Watch the Deliveries tab, and re-send failed deliveries from the logs. This isn't available when **Use lightweight logs** is on, so leave it off unless payloads are very large.

**How Hookdeck Can Help:** Hookdeck accepts each Halo delivery as it arrives, then delivers it to your service with [retries](/docs/retries) you configure, and [issues](/docs/issues) alert you when your service starts failing.

### Basic credentials and challenges

**The Problem:** A receiver that expects to challenge with a `401` and `WWW-Authenticate` before getting credentials never sees them.

**Why It Happens:** Halo's guide doesn't say whether Basic credentials are sent on the first request. Hookdeck doesn't send a challenge.

**Workarounds:**

* Check the first delivery for an `Authorization: Basic` header.
* Fail closed if it's missing rather than relying on a challenge.

**How Hookdeck Can Help:** Hookdeck's HaloPSA source checks the Basic credentials on each request. Hookdeck answers Halo with a `200` either way, then records a request without valid credentials as failed verification, so it never reaches your destination.

## Best practices

### Accept an object or an array

Normalise at the top of the handler so batching is a configuration change, not a code change.

### Deduplicate on the delivery's IDs

Store the webhook's ID and object ID, together with the event and timestamp, and skip repeats. Re-sending from logs can deliver the same event twice. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use conditions to keep volume down

Filter events in Halo with the same conditions you'd use on notifications, rather than receiving every ticket change and discarding most of them.

### Acknowledge fast and process asynchronously

Verify, store, respond with `200`, then process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

HaloPSA sends a JSON `POST` for each configured notification event, or a JSON array when batching is on, with Basic Authentication as the only credential. Check the username and password in constant time over HTTPS, read the exact payload keys from a real delivery, accept both objects and arrays, and acknowledge quickly.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Basic credentials, keeps every delivery, and retries delivery to your service, which fills the gap left by Halo's undocumented retry behaviour.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle HaloPSA webhooks reliably in minutes.