# Guide to MaintainX Webhooks: Features and Best Practices

MaintainX webhooks tell your systems what is happening on the maintenance floor: a work order is created or closed, an asset goes offline, a part runs low, or a purchase order changes status. If you run maintenance on MaintainX, the CMMS platform, webhooks are how that activity reaches your ERP, ticketing tools, dashboards and alerting without polling the REST API.

This guide covers how MaintainX webhooks are created and signed, the events you can subscribe to and the shape of their payloads, the gaps in what MaintainX documents about delivery, and the best practices for running a MaintainX receiver in production.

## What are MaintainX webhooks?

A MaintainX webhook is a subscription that pairs one event type with one endpoint URL. When the event occurs, MaintainX sends an HTTP `POST` with a JSON body to the URL. To receive new work orders and work order status changes, you create two webhooks. They can point at the same endpoint, because every body carries an `eventType` field that names the subscription it came from.

Every request carries two signature headers. `x-maintainx-webhook-body-signature` signs the request body, and `x-maintainx-webhook-uri-signature` signs the full request URI. Both use the same `t=<timestamp>,v1=<signature>` format, an HMAC-SHA256 keyed with the webhook's own secret. Most receivers verify the body signature, because that is the one that protects the payload.

## MaintainX webhook features

| Feature | Details |
| --- | --- |
| Configuration | Web app (**Settings > Integrations > Webhooks**) or the REST API (`POST /subscriptions`) |
| Subscription model | One event type per webhook. Several webhooks can share a URL |
| Event types | 50, listed in the `x-webhooks` object of MaintainX's public OpenAPI document |
| Discriminator | `eventType` in the JSON body |
| Signature headers | `x-maintainx-webhook-body-signature` and `x-maintainx-webhook-uri-signature` |
| Header format | `t=<timestamp>,v1=<hex>`. `v1` is the only live scheme |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `<t>.<raw body>` for the body header, `<t>.<full URI>` for the URI header |
| Secret | One per webhook, used as a plain string |
| Replay window | MaintainX recommends 5 minutes. The timestamp's unit is not documented |
| Response window | 10 seconds |
| Retries | Not documented |
| Failing webhooks | May be deleted after long periods without a successful response, with an email to the user who created them |
| URL limit | 512 characters |

MaintainX publishes no source IP list for webhooks, so the signature is how you authenticate a request.

## Common events

The 50 event types fall into a handful of groups. Each one is a separate subscription.

| Area | Event types | Fires when |
| --- | --- | --- |
| Work orders | `NEW_WORK_ORDER`, `WORK_ORDER_CHANGE`, `WORK_ORDER_STATUS_CHANGE`, `WORK_ORDER_DELETE`, `WORK_ORDER_OVERDUE`, `WORK_ORDER_PART_STATUS_CHANGE`, `NEW_COMMENT_ON_WORK_ORDER`, `NEW_CATEGORY_ON_WORK_ORDER` | A work order is created, edited, moved between statuses, deleted, goes overdue or gets a comment |
| Work requests | `NEW_WORK_REQUEST`, `WORK_REQUEST_STATUS_CHANGE` | A request is submitted or changes status |
| Assets and meters | `NEW_ASSET`, `ASSET_CHANGE`, `ASSET_STATUS_CHANGE`, `METER_TRIGGER_STATE_CHANGE` | An asset is created, edited or changes status, or a meter trigger changes state |
| Parts and purchasing | `NEW_PART`, `PART_CHANGE`, `PART_QUANTITY_CHANGE`, `PART_DELETE`, `PART_TRANSFER`, `NEW_PURCHASE_ORDER`, `PURCHASE_ORDER_CHANGE`, `PURCHASE_ORDER_STATUS_CHANGE` | Inventory or a purchase order changes |
| Locations, customers and vendors | `NEW_LOCATION`, `LOCATION_CHANGE`, `NEW_CUSTOMER`, `CUSTOMER_CHANGE`, `NEW_VENDOR` | One of these records is created or edited |
| Users, roles and access | `USER_INVITED`, `USER_REMOVED`, `USER_ROLE_CHANGED`, `USER_LOGIN_SUCCESS`, `USER_LOGIN_FAILURE`, `ROLE_CREATED`, `API_TOKEN_CREATED`, `API_TOKEN_REVOKED`, `SSO_CONFIG_CHANGED`, `WEBHOOK_CREATED` and more | An account, permission, token or integration setting changes |

The `*_CHANGE` events fire only for specific properties. MaintainX lists them in each event's description: `WORK_ORDER_CHANGE`, for example, fires for changes to the asset, assignees, categories, costs, customer, description, due date, estimated time, extra fields, location, parts, priority, start date, teams, title or vendors. `WORK_ORDER_STATUS_CHANGE` covers the `OPEN`, `IN_PROGRESS`, `ON_HOLD` and `DONE` statuses.

Every body is a flat JSON object. Most share `eventType`, `orgId` and `occurredAt`, then add the resource's IDs and before and after values. Here is the shape of a `WORK_ORDER_STATUS_CHANGE`, built from MaintainX's schema and its example values:

```json
{
  "eventType": "WORK_ORDER_STATUS_CHANGE",
  "workOrderId": 123456,
  "orgId": 321,
  "occurredAt": "2022-01-01T00:00:00.000Z",
  "oldStatus": "IN_PROGRESS",
  "newStatus": "DONE",
  "progress": { "inProgressCount": 2, "onHoldCount": 1, "doneCount": 0 },
  "userId": 789
}

```

`userId` is present when a person triggered the event and absent for system-initiated events, such as automations and scheduled runs. MaintainX says to treat its absence as the signal that the system acted. Some events can include the full record: for example, the `ASSET_CHANGE` subscription has a `serializeEntity` option that sends the asset's data with the event.

## Setting up MaintainX webhooks

1. In the MaintainX web app, select **Settings**, then **Integrations**, and open the **Webhooks** tab.
2. Select **+ New Webhook**.
3. In **Trigger Webhook On**, select the event, and in **Endpoint**, enter your HTTPS URL.
4. Save, then find the webhook in the list and select **Copy secret**. Store the value server-side, for example as `MAINTAINX_WEBHOOK_SECRET`.
5. Repeat for each event you need. Each webhook has its own secret.

To create webhooks from code, use the REST API at `https://api.getmaintainx.com/v1`:

```bash
curl -X POST "https://api.getmaintainx.com/v1/subscriptions" \
  -H "Authorization: Bearer $MAINTAINX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"WORK_ORDER_STATUS_CHANGE","url":"https://example.com/webhooks/maintainx"}'

```

`GET /subscriptions/{id}/secret` returns the webhook's secret, and `PATCH` and `DELETE` on `/subscriptions/{id}` update or remove it. MaintainX documents no test button, so trigger a real event, such as creating a work order, to send your first delivery.

## Securing MaintainX webhooks

To verify a request, split `x-maintainx-webhook-body-signature` on `,` and each element on `=` to get `t` and `v1`. Build the string `<t>.<raw body>`, compute a hex HMAC-SHA256 of it with the webhook's secret as the key, and compare the result with `v1` in constant time.

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

// Optional replay window in seconds. 0 leaves it off: MaintainX does not state the
// timestamp's unit, and a replayed or retried delivery keeps its original timestamp.
const TOLERANCE_SECONDS = 0;

function parseSignatureHeader(header) {
  const parts = {};
  for (const element of header.split(',')) {
    const index = element.indexOf('=');
    if (index > 0) parts[element.slice(0, index).trim()] = element.slice(index + 1).trim();
  }
  return parts;
}

function verifyMaintainxSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  const { t: timestamp, v1: signature } = parseSignatureHeader(header);
  if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false;

  if (TOLERANCE_SECONDS > 0) {
    // Assumes seconds. Check the unit against a real delivery before you turn this on.
    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (age > TOLERANCE_SECONDS) return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), rawBody]))
    .digest('hex');

  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/maintainx', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.MAINTAINX_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyMaintainxSignature(req.body, req.get('x-maintainx-webhook-body-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.eventType. Persist the event, then process it asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_maintainx_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature or not (timestamp.isascii() and timestamp.isdigit()):
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_maintainx_signature(raw_body,
#     request.headers.get("x-maintainx-webhook-body-signature"),
#     os.environ["MAINTAINX_WEBHOOK_SECRET"])

```

MaintainX recommends rejecting requests older than 5 minutes, but it does not say whether `t` is in seconds or milliseconds. If you add the check, confirm the unit from a real delivery first, and remember that a request you replay later carries its original timestamp and will fail a strict window.

To check your verifier, use the secret `maintainx-test-secret`, the header value `t=1767268800,v1=731678b27c4196aebbd85712deca8c4c17b57d0d887d5462f39e165c01d8b77f` and this exact body:

```
{"eventType":"WORK_ORDER_STATUS_CHANGE","workOrderId":123456,"orgId":321,"occurredAt":"2026-01-01T12:00:00.000Z","oldStatus":"OPEN","newStatus":"DONE"}

```

> **Make MaintainX webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `x-maintainx-webhook-body-signature` header, durably queues every work order, asset and inventory event, and retries delivery to your service on a schedule you control.

## MaintainX webhook limitations and pain points

### No documented retries

**The Problem:** If your endpoint is down or slow when an event fires, you may never see that event.

**Why It Happens:** MaintainX documents a 10-second timeout and says long-failing webhooks may be deleted, but it does not say whether, when or how often a failed delivery is retried. Its own best practices tell integrators to implement their own retry logic.

**Workarounds:**

* Acknowledge within the 10-second window and do the work afterwards.
* Reconcile periodically against the REST API for records where a missed event would matter.
* Monitor your endpoint so you notice failures before MaintainX deletes the webhook.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then retries against your service on a schedule you control and lets you replay any event, so an outage on your side costs latency rather than lost events.

### A failing endpoint can lose its webhooks

**The Problem:** After a long enough outage, the subscription itself disappears and nothing arrives until someone recreates it.

**Why It Happens:** MaintainX may delete webhooks that do not get a successful response "over long periods of time". It emails the user who created the webhook, which may not be the person running the integration.

**Workarounds:**

* Create webhooks from a shared service account whose email goes to the team that owns the integration.
* Keep the webhook definitions in code so you can recreate them with `POST /subscriptions`.
* Periodically list your subscriptions and alert if one is missing.

**How Hookdeck Can Help:** Because Hookdeck answers MaintainX as soon as a request arrives, your own downtime does not show up as failed deliveries on MaintainX's side.

### One subscription per event type

**The Problem:** A full integration needs dozens of webhooks, each with its own secret.

**Why It Happens:** Each webhook covers exactly one event type, and MaintainX issues a separate secret per webhook. A receiver that shares one URL across many subscriptions has to know which secret to use.

**Workarounds:**

* Give each subscription its own path, such as `/webhooks/maintainx/work-orders`, and map paths to secrets.
* Or try each stored secret in turn until one verifies, and log which one matched.
* Script subscription creation so secrets are captured as they are created.

**How Hookdeck Can Help:** Create one Hookdeck source per MaintainX webhook, each with its own secret, and route them all to the same destination. Hookdeck verifies each one with the right key before anything reaches your service.

### The URI signature breaks behind a proxy

**The Problem:** Verifying `x-maintainx-webhook-uri-signature` fails even though the body signature passes.

**Why It Happens:** The URI signature covers the full URL that MaintainX called. If a proxy, load balancer or gateway sits in front of your service, the URL your code sees is not the URL MaintainX signed.

**Workarounds:**

* Verify the body signature, which protects the payload and does not depend on the URL.
* If you also check the URI signature, reconstruct the public URL from your configuration rather than from the request.

**How Hookdeck Can Help:** Hookdeck's MaintainX source verifies the body signature, which stays valid however the request is routed.

## Best practices

### Verify against the raw body

The signature covers the exact bytes MaintainX sent, prefixed with the timestamp and a dot. In Express, mount `express.raw()` on the webhook route and parse only after verification. In FastAPI, read `await request.body()`. Re-serialising the JSON changes the bytes, and the signature will not match.

### Route on `eventType`

Because many subscriptions can share a URL, one handler usually receives several event types. Switch on `eventType`, and log and acknowledge anything you do not recognise so a new subscription never turns into a run of failures.

### Treat `userId` as optional

Do not assume every event has a user. System-initiated events, such as automations and scheduled runs, arrive without `userId`, and code that requires it will throw on exactly the events you are least likely to be watching.

### Deduplicate deliveries

MaintainX payloads carry no event ID. Build an idempotency key from `eventType`, the resource ID (`workOrderId`, `assetId` and so on) and `occurredAt`, and store it before you process the event. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

MaintainX waits 10 seconds. Verify, persist the event, return `2xx`, and do the real work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

MaintainX signs every request with a hex HMAC-SHA256 over `<t>.<raw body>`, sent in `x-maintainx-webhook-body-signature` and keyed with a secret that belongs to that one webhook. Verify against the raw bytes, route on `eventType`, treat `userId` as optional, and acknowledge within 10 seconds. MaintainX documents no retries and may delete webhooks that keep failing, so plan for missed events rather than assuming redelivery.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies the body signature on every request, and holds each event until your service has processed it, so an outage on your side does not cost you events or subscriptions.

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