# Guide to NinjaOne Webhooks: Features and Best Practices

NinjaOne, formerly NinjaRMM, is an endpoint management platform used by managed service providers and IT teams to monitor and patch devices, run scripts and handle tickets. Its API webhook pushes those activities to your own services as they happen: a monitoring condition firing or resetting, a script finishing, a patch failing, a device being added. If you sync NinjaOne with a PSA, a data warehouse or your own alerting, webhooks are how you avoid polling the activity log.

This guide covers how the NinjaOne API webhook is configured, how to authenticate it without a signature, what an activity payload looks like, and the gaps in NinjaOne's documentation that you should design around.

## What are NinjaOne webhooks?

NinjaOne has two things called webhooks, and they behave differently:

* **The API webhook.** You configure it through the NinjaOne Public API with `PUT /v2/webhook`. It sends activity notifications as JSON, and it can add custom headers to every request.
* **Webhook notification channels.** These are set up in the NinjaOne console to send alert notifications to a URL. Only the URL can be configured.

This guide is about the API webhook. Because a notification channel can't send a custom header, and NinjaOne doesn't sign either kind, there is no way to authenticate notification channel requests.

The API webhook belongs to the API application, or client, that configures it. Each delivery is a `POST` whose body is, in NinjaOne's words, "very similar to the Activity object" from the API.

## NinjaOne webhook features

| Feature | Details |
| --- | --- |
| Configuration | `PUT /v2/webhook` on your region's API host, per API application |
| Who can configure | System administrators only. Others get `403` |
| Disable | `DELETE /v2/webhook` |
| Filters | Activity types and organisation IDs |
| Expansions | `device`, `organization`, `location`, `policy`, `rolePolicy`, `role` |
| Method and body | `POST`, JSON similar to the Activity object |
| Event type | `activityType` in the body, refined by `statusCode` |
| Signature | None |
| Authentication | Custom headers you define, such as `Authorization` |
| Retries, timeout, success code | Not documented |
| Delivery headers | None documented beyond your own |
| Source IPs | Not documented for webhooks |

The API host depends on where your NinjaOne account lives:

| Region | Host |
| --- | --- |
| North America | `https://app.ninjarmm.com` |
| EMEA | `https://eu.ninjarmm.com` |
| APAC | `https://oc.ninjarmm.com` |

## What an activity contains

NinjaOne publishes a JSON Schema for the payload but no example delivery. The fields are:

| Field | Description |
| --- | --- |
| `id` | Activity identifier |
| `activityTime` | Activity timestamp, as a number |
| `deviceId` | Device identifier |
| `severity` | `NONE`, `MINOR`, `MODERATE`, `MAJOR` or `CRITICAL` |
| `priority` | `NONE`, `LOW`, `MEDIUM` or `HIGH` |
| `seriesUid` | The job or condition UID the activity belongs to |
| `activityType` | Activity type code, such as `CONDITION`, `ACTION`, `ACTIONSET`, `PATCH_MANAGEMENT`, `SCRIPTING` or `SYSTEM` |
| `statusCode` | Activity status code, such as `TRIGGERED`, `RESET`, `STARTED`, `COMPLETED`, `NODE_CREATED` or `NODE_UPDATED` |
| `status` | Status description |
| `activityResult` | `SUCCESS`, `FAILURE`, `UNSUPPORTED` or `UNCOMPLETED` |
| `sourceConfigUid`, `sourceName` | The policy element or configuration that produced the activity |
| `subject`, `message` | Text describing the activity |
| `userId` | User identifier |
| `type` | Activity type description |
| `data` | Additional activity data, as an object |

Each expansion you request adds the full referenced object to the payload, such as the device record or the organisation.

`activityType` has 38 documented values, and `statusCode` has several hundred, covering conditions, patching, scripting, backups, remote tools, ticketing and integrations.

## Setting up NinjaOne webhooks

1. Get an access token for your NinjaOne API application. The webhook is configured for that application, and only system administrators can configure it.
2. Generate a long random secret to authenticate deliveries, for example 32 bytes from a secure random generator.
3. Call `PUT /v2/webhook` on your region's host:

```json
{
  "url": "https://example.com/webhooks/ninjaone",
  "activities": {
    "CONDITION": ["*"],
    "PATCH_MANAGEMENT": ["*"]
  },
  "organizationIds": [1, 2],
  "expand": ["device", "organization"],
  "headers": [
    { "name": "Authorization", "value": "Bearer <your-secret>" }
  ]
}

```

1. Expect `204 No Content`. A `403` means the token doesn't belong to a system administrator.

The value for each activity type is "reserved for future more granular activity filtering", so use `["*"]`. Calling `PUT /v2/webhook` again replaces the configuration, and `DELETE /v2/webhook` disables it.

## Securing NinjaOne webhooks

NinjaOne doesn't sign webhooks. There is no signature header, no HMAC and no signing secret. The only authentication available is a header you add through the `headers` array, which NinjaOne describes as "Custom HTTP Headers (i.e. Authorization)". Your receiver compares that header with the value it expects, in constant time.

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

function verifyNinjaOneHeader(headerValue, expectedValue) {
  if (!headerValue || !expectedValue) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(expectedValue, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/ninjaone', express.raw({ type: '*/*' }), (req, res) => {
  const expected_value = process.env.NINJAONE_WEBHOOK_AUTH; // e.g. "Bearer <your-secret>"
  if (!expected_value) return res.sendStatus(500); // fail closed

  if (!verifyNinjaOneHeader(req.get('authorization'), expected_value)) {
    return res.sendStatus(401);
  }

  const activity = JSON.parse(req.body.toString('utf8'));
  // activity.activityType, activity.statusCode, activity.id ...
  // Store it, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac

def verify_ninjaone_header(header_value, expected_value: str) -> bool:
    if not header_value or not expected_value:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), expected_value.encode("utf-8"))

# In FastAPI:
# verify_ninjaone_header(request.headers.get("authorization"),
#                        os.environ["NINJAONE_WEBHOOK_AUTH"])

```

A static header proves the request came from someone who knows the secret. It doesn't protect the body from changes in transit or stop a captured request from being replayed, so always use an HTTPS URL and keep the secret out of logs.

In Hookdeck, the NinjaOne source type checks the header for you. Enter the header name, such as `Authorization`, and the exact value you put in the `headers` array, including any `Bearer ` prefix.

> **Make NinjaOne webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header, stores every activity as it arrives, and delivers it to your service with retries you control.

## NinjaOne webhook limitations and pain points

### No signature

**The Problem:** Anyone who obtains your secret header can send requests that look like NinjaOne's, with any body they like.

**Why It Happens:** NinjaOne only lets you add static headers. Nothing in the request is computed from the body.

**Workarounds:**

* Use a long random secret, HTTPS only, and rotate it by calling `PUT /v2/webhook` with a new value.
* Treat the payload as a notification. Before you take a significant action, fetch the activity or device from the NinjaOne API.
* Use the console's notification channels only for alerts where an unauthenticated request does no harm.

**How Hookdeck Can Help:** Hookdeck checks the header at the [source](/docs/sources) and records requests that fail, so you can see when something other than NinjaOne is calling your URL.

### Delivery behaviour is undocumented

**The Problem:** You can't tell whether NinjaOne retries a failed delivery, how long it waits for a response, or which status codes count as success.

**Why It Happens:** The webhook documentation covers configuration and the payload schema only. It says nothing about retries, timeouts, ordering or delivery IDs.

**Workarounds:**

* Respond with `200` quickly and do the work afterwards.
* Assume a failed delivery may be lost, and reconcile by reading activities from the API on a schedule.

**How Hookdeck Can Help:** Hookdeck accepts each activity at its edge and stores it, so the gap you need to worry about is only between NinjaOne and Hookdeck. From there, Hookdeck delivers to your service with [retries](/docs/retries) and lets you replay any event.

### Large payloads from expansions

**The Problem:** Payloads get large, and every activity repeats the same device and organisation data.

**Why It Happens:** Each expansion embeds a full object. NinjaOne warns that payloads become large depending on the expansions you choose, and encourages you to keep a local cache of core entities loaded from the Public API instead.

**Workarounds:**

* Request only the expansions you need, or none.
* Cache devices, organisations and policies locally, keyed by the IDs in the activity.

**How Hookdeck Can Help:** [Transformations](/docs/transformations) can strip fields you don't need before an event reaches your service.

### Conflicting lists of activity types

**The Problem:** You can't be sure which activity types you can subscribe to.

**Why It Happens:** The payload schema lists 38 activity types, but the filter in `PUT /v2/webhook` lists only 30. The other eight, including `NINJA_TICKETING`, `DOCUMENTATION`, `MICROSOFT_INTUNE` and `DYNAMIC_POLICY`, appear in the payload schema but not in the filter, and NinjaOne doesn't say whether they can be subscribed to.

**Workarounds:**

* Log and acknowledge activity types you don't handle instead of rejecting them.
* Test a subscription to any of the eight before you depend on it.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `activityType` and `statusCode`, so new or unexpected types can go to a separate destination for review.

## Best practices

### Compare the header in constant time

Use `crypto.timingSafeEqual` or `hmac.compare_digest`, check the length first in Node, and fail closed if the expected value isn't configured.

### Route on `activityType` and `statusCode`

`activityType` tells you which part of NinjaOne produced the activity, and `statusCode` tells you what happened, such as `TRIGGERED` or `RESET` for a condition. Use `activityResult` when you need to know whether a job succeeded.

### Deduplicate on the activity `id`

NinjaOne documents no delivery ID or retry behaviour. The activity's `id` identifies the activity, so record it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep expansions small

Fewer expansions mean smaller requests and faster handling. Cache the entities you need and refresh them from the API.

### Acknowledge fast and process asynchronously

Check the header, store the activity, return `200`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

The NinjaOne API webhook is configured with a single API call, filtered by activity type and organisation, and authenticated only by a header you add yourself. With no signature, no published example payload and nothing documented about retries, the safe approach is to compare the header carefully, treat each activity as a notification, and reconcile against the API.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks your secret header, stores every activity, and delivers it to your service with retries you control.

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