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_, NEW_, NEW_ | A work order is created, edited, moved between statuses, deleted, goes overdue or gets a comment |
| Work requests | NEW_WORK_REQUEST, WORK_ | A request is submitted or changes status |
| Assets and meters | NEW_ASSET, ASSET_CHANGE, ASSET_STATUS_CHANGE, METER_ | 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_ | 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:
{
"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
- In the MaintainX web app, select Settings, then Integrations, and open the Webhooks tab.
- Select + New Webhook.
- In Trigger Webhook On, select the event, and in Endpoint, enter your HTTPS URL.
- Save, then find the webhook in the list and select Copy secret. Store the value server-side, for example as
MAINTAINX_WEBHOOK_SECRET. - 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:
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.
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:
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 verifies the
x-maintainx-webhook-body-signatureheader, 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.
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.
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 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 for free and handle MaintainX webhooks reliably in minutes.