Mastering the MAS REST API: A Practitioner's Guide

Series: MAS INTEGRATION -- From Legacy MIF to Cloud-Native Integration | Part 4 of 8

Read Time: 25 minutes

Who this is for: Integration developers, backend engineers, and automation specialists who need to build, query, and manage Maximo data programmatically through the REST API. This is a hands-on reference -- code first, theory second.
The bottom line: This is the post you will bookmark. Every authentication method, every CRUD operation, every query pattern, every error code -- documented with working code examples in curl, Python, and Node.js. When you are building integrations at 2 AM and need the exact syntax, this is where you come back.

The API You Will Actually Use

Parts 2 and 3 of this series gave you the conceptual foundation: API-first architecture, event-driven patterns, the paradigm shift from MIF. This post is different. This post is the hands-on reference. You are going to see real HTTP requests, real response bodies, real error messages, and real code that handles all of it.

Every example in this post targets the OSLC-based REST API that ships with MAS Manage. The base URL pattern you will use throughout is:

https://{mas-host}/maximo/oslc/os/{objectstructure}

Common object structures you will work with daily:

Object Structure — Resource — Description

mxwo — Work Orders — Work order management

mxasset — Assets — Asset records

mxsr — Service Requests — Service request management

mxpo — Purchase Orders — Procurement

mxinventory — Inventory — Inventory balances

mxperson — Persons — Person records

mxitem — Items — Item master

mxloc — Locations — Location hierarchy

Let us start where every integration starts: getting authenticated.

Authentication Deep Dive

MAS supports three authentication methods. Each serves a different use case. Pick the right one and your integration life gets dramatically simpler.

Method 1: API Key Authentication

API keys are the simplest authentication method and the right choice for server-to-server integrations, batch jobs, and automation scripts. You generate them in MAS Administration and pass them as a header on every request.

Generating an API Key:

  1. Log into MAS Administration
  2. Navigate to Administration > API Keys
  3. Click Add API Key
  4. Associate the key with a user account (this determines permissions)
  5. Copy the key -- you will not see it again

curl:

# Simple authenticated request with API key
curl -X GET "https://mas-host/maximo/oslc/os/mxwo?oslc.pageSize=1" \
  -H "apikey: a1b2c3d4e5f6g7h8i9j0" \
  -H "Accept: application/json"

Python:

import requests

BASE_URL = "https://mas-host/maximo/oslc/os"
API_KEY = "a1b2c3d4e5f6g7h8i9j0"

headers = {
    "apikey": API_KEY,
    "Accept": "application/json"
}

response = requests.get(f"{BASE_URL}/mxwo?oslc.pageSize=1", headers=headers)
print(response.status_code)
print(response.json())

Node.js:

const axios = require('axios');

const BASE_URL = 'https://mas-host/maximo/oslc/os';
const API_KEY = 'a1b2c3d4e5f6g7h8i9j0';

async function getWorkOrder() {
  const response = await axios.get(`${BASE_URL}/mxwo`, {
    params: { 'oslc.pageSize': 1 },
    headers: {
      'apikey': API_KEY,
      'Accept': 'application/json'
    }
  });
  console.log(response.data);
}

getWorkOrder();

When to use API keys: Scheduled jobs, middleware integrations, scripts that run under a service account. The key inherits permissions from its associated user, so create a dedicated service account with exactly the permissions your integration needs -- no more, no less.

Method 2: OAuth 2.0 Client Credentials

OAuth 2.0 is the recommended method when you need user-context operations or when your organization's security policy requires token-based authentication with expiration. MAS uses the OpenID Connect (OIDC) protocol backed by an identity provider -- typically IBM Cloud Pak foundational services or an external IdP like Azure AD.

Python:

import requests

# Step 1: Get OAuth token
token_url = "https://mas-host/auth/realms/mas/protocol/openid-connect/token"
token_response = requests.post(token_url, data={
    'grant_type': 'client_credentials',
    'client_id': 'my-integration-app',
    'client_secret': 'your-client-secret'
})
access_token = token_response.json()['access_token']

# Step 2: Use token in API calls
headers = {
    'Authorization': f'Bearer {access_token}',
    'Content-Type': 'application/json'
}

response = requests.get(
    "https://mas-host/maximo/oslc/os/mxwo?oslc.pageSize=10",
    headers=headers
)
print(response.json())

Node.js:

const axios = require('axios');

async function getOAuthToken() {
  const tokenUrl = 'https://mas-host/auth/realms/mas/protocol/openid-connect/token';

  const response = await axios.post(tokenUrl, new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: 'my-integration-app',
    client_secret: 'your-client-secret'
  }), {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  });

  return response.data.access_token;
}

async function queryWorkOrders() {
  const token = await getOAuthToken();

  const response = await axios.get('https://mas-host/maximo/oslc/os/mxwo', {
    params: { 'oslc.pageSize': 10 },
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json'
    }
  });

  console.log(response.data);
}

queryWorkOrders();

Token management tip: OAuth tokens expire. In production, cache the token and refresh it before expiration. A typical access token lifetime is 300 seconds (5 minutes). Do not request a new token on every API call -- that is wasteful and may trigger rate limits on the identity provider.

Method 3: OIDC Discovery with openid-client

For Node.js applications that need the full OIDC flow -- including automatic discovery of endpoints and token refresh -- use the openid-client library. This is the most robust approach for long-running Node.js services.

const { Issuer } = require('openid-client');

async function getMaximoClient() {
  // Discover OIDC endpoints automatically
  const issuer = await Issuer.discover('https://mas-host/auth/realms/mas');
  console.log('Discovered issuer:', issuer.metadata.issuer);

  // Create client
  const client = new issuer.Client({
    client_id: 'my-integration-app',
    client_secret: 'your-client-secret'
  });

  // Get token using client credentials grant
  const tokenSet = await client.grant({ grant_type: 'client_credentials' });
  console.log('Token expires at:', tokenSet.expires_at);

  return tokenSet.access_token;
}

async function fetchAssets() {
  const token = await getMaximoClient();

  const response = await fetch('https://mas-host/maximo/oslc/os/mxasset?oslc.pageSize=5', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json'
    }
  });

  const data = await response.json();
  console.log(`Found ${data.member.length} assets`);
  return data;
}

fetchAssets();

When to use OIDC discovery: Production Node.js services that need automatic endpoint resolution, token refresh, and standards-compliant OIDC flows. The discovery mechanism means you do not hardcode token endpoints -- if the IdP configuration changes, your code adapts automatically.

Authentication Method Comparison

Method — Best For — Complexity — Token Expiry — User Context

API Key — Server-to-server, scripts, batch jobs — Low — None (rotate manually) — Service account

OAuth 2.0 Client Credentials — Application-to-application — Medium — 5-15 minutes — Service account

OIDC with openid-client — Long-running Node.js services — Higher — Auto-refresh capable — Service or user

CRUD Operations

Every integration boils down to four operations: Create, Read, Update, Delete. Here is how each one works in the MAS REST API, with complete examples.

CREATE -- POST

Creating a record sends a POST request with a JSON payload. The response includes the URI of the newly created resource. You can create parent and child records in a single call by nesting related objects in the payload.

curl -- Create a work order with planned materials:

curl -X POST "https://mas-host/maximo/oslc/os/mxwo" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "siteid": "BEDFORD",
    "description": "Replace pump motor - Building A",
    "classstructureid": "1007",
    "wopriority": 2,
    "worktype": "CM",
    "wpmaterial": [
      {
        "itemnum": "PUMP-100",
        "itemqty": 1,
        "description": "Centrifugal pump motor"
      }
    ]
  }'

Python -- Create with planned materials and labor:

import requests

BASE_URL = "https://mas-host/maximo/oslc/os"
headers = {
    "apikey": "YOUR_API_KEY",
    "Content-Type": "application/json"
}

work_order = {
    "siteid": "BEDFORD",
    "description": "Replace pump motor - Building A",
    "classstructureid": "1007",
    "wopriority": 2,
    "worktype": "CM",
    "wpmaterial": [
        {
            "itemnum": "PUMP-100",
            "itemqty": 1,
            "description": "Centrifugal pump motor"
        }
    ],
    "wplabor": [
        {
            "laborcode": "SMITH",
            "laborhrs": 4,
            "craft": "MECHANIC"
        }
    ]
}

response = requests.post(f"{BASE_URL}/mxwo", headers=headers, json=work_order)

if response.status_code == 201:
    # The Location header contains the URI of the new record
    new_wo_uri = response.headers.get("Location")
    print(f"Created work order: {new_wo_uri}")
else:
    print(f"Error {response.status_code}: {response.text}")

Node.js -- Create a work order:

const axios = require('axios');

const BASE_URL = 'https://mas-host/maximo/oslc/os';
const headers = {
  'apikey': 'YOUR_API_KEY',
  'Content-Type': 'application/json'
};

async function createWorkOrder() {
  const workOrder = {
    siteid: 'BEDFORD',
    description: 'Replace pump motor - Building A',
    classstructureid: '1007',
    wopriority: 2,
    worktype: 'CM',
    wpmaterial: [
      {
        itemnum: 'PUMP-100',
        itemqty: 1,
        description: 'Centrifugal pump motor'
      }
    ]
  };

  try {
    const response = await axios.post(`${BASE_URL}/mxwo`, workOrder, { headers });
    console.log('Created:', response.headers.location);
    return response.headers.location;
  } catch (error) {
    console.error('Error:', error.response?.status, error.response?.data);
  }
}

createWorkOrder();

Key points about CREATE:

  • A successful POST returns HTTP 201 with a Location header pointing to the new resource.
  • Nested child records (like wpmaterial and wplabor) are created atomically with the parent.
  • Business rules, validation, and workflows fire just as they would through the UI. If a required field is missing, you get a 400 error with a descriptive message.

READ -- GET

Reading records is where you will spend most of your API time. GET requests support rich query parameters for filtering, selecting fields, sorting, and paginating results.

curl -- Get a single work order by ID:

curl -X GET "https://mas-host/maximo/oslc/os/mxwo/12345" \
  -H "apikey: YOUR_API_KEY" \
  -H "Accept: application/json"

curl -- Query work orders with filters:

# Get all open, high-priority work orders at BEDFORD
curl -X GET "https://mas-host/maximo/oslc/os/mxwo?\
oslc.where=siteid=%22BEDFORD%22%20and%20status=%22APPR%22%20and%20wopriority%3C=2&\
oslc.select=wonum,description,status,wopriority,reportdate&\
oslc.pageSize=50&\
oslc.orderBy=%2Breportdate" \
  -H "apikey: YOUR_API_KEY" \
  -H "Accept: application/json"

Python -- Query with readable parameter construction:

import requests
from urllib.parse import quote

BASE_URL = "https://mas-host/maximo/oslc/os"
headers = {"apikey": "YOUR_API_KEY", "Accept": "application/json"}

# Build query parameters clearly
params = {
    "oslc.where": 'siteid="BEDFORD" and status="APPR" and wopriority<=2',
    "oslc.select": "wonum,description,status,wopriority,reportdate",
    "oslc.pageSize": 50,
    "oslc.orderBy": "+reportdate"
}

response = requests.get(f"{BASE_URL}/mxwo", headers=headers, params=params)
data = response.json()

for wo in data.get("member", []):
    print(f"{wo['wonum']}: {wo['description']} [{wo['status']}]")

Node.js -- Query with async/await:

const axios = require('axios');

const BASE_URL = 'https://mas-host/maximo/oslc/os';
const headers = {
  'apikey': 'YOUR_API_KEY',
  'Accept': 'application/json'
};

async function getOpenWorkOrders() {
  const response = await axios.get(`${BASE_URL}/mxwo`, {
    headers,
    params: {
      'oslc.where': 'siteid="BEDFORD" and status="APPR" and wopriority<=2',
      'oslc.select': 'wonum,description,status,wopriority,reportdate',
      'oslc.pageSize': 50,
      'oslc.orderBy': '+reportdate'
    }
  });

  const workOrders = response.data.member || [];
  workOrders.forEach(wo => {
    console.log(`${wo.wonum}: ${wo.description} [${wo.status}]`);
  });

  return workOrders;
}

getOpenWorkOrders();

UPDATE -- POST with x-method-override or PATCH

MAS supports two approaches for updating records. The PATCH method is the standard HTTP approach. The POST with x-method-override: PATCH header is the alternative when your HTTP client or middleware does not support PATCH.

curl -- Update using PATCH:

curl -X PATCH "https://mas-host/maximo/oslc/os/mxwo/12345" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "x-method-override: PATCH" \
  -d '{
    "description": "Replace pump motor - Building A (URGENT)",
    "wopriority": 1
  }'

Python -- Update with optimistic locking:

import requests

BASE_URL = "https://mas-host/maximo/oslc/os"
headers = {
    "apikey": "YOUR_API_KEY",
    "Content-Type": "application/json",
    "x-method-override": "PATCH"
}

# First, GET the record to obtain the ETag for optimistic locking
wo_url = f"{BASE_URL}/mxwo/12345"
get_response = requests.get(wo_url, headers={"apikey": "YOUR_API_KEY"})

# Use the ETag in the If-Match header to prevent concurrent update conflicts
etag = get_response.headers.get("ETag")
if etag:
    headers["If-Match"] = etag

update_data = {
    "description": "Replace pump motor - Building A (URGENT)",
    "wopriority": 1
}

response = requests.post(wo_url, headers=headers, json=update_data)

if response.status_code == 204:
    print("Work order updated successfully")
elif response.status_code == 412:
    print("Conflict: record was modified by another user. Refresh and retry.")
else:
    print(f"Error {response.status_code}: {response.text}")

Node.js -- Update with error handling:

const axios = require('axios');

const BASE_URL = 'https://mas-host/maximo/oslc/os';

async function updateWorkOrder(woId, updates) {
  const woUrl = `${BASE_URL}/mxwo/${woId}`;

  // Get current ETag for optimistic locking
  const getResponse = await axios.get(woUrl, {
    headers: { 'apikey': 'YOUR_API_KEY' }
  });

  const etag = getResponse.headers.etag;

  try {
    const response = await axios.post(woUrl, updates, {
      headers: {
        'apikey': 'YOUR_API_KEY',
        'Content-Type': 'application/json',
        'x-method-override': 'PATCH',
        ...(etag && { 'If-Match': etag })
      }
    });
    console.log('Updated successfully');
    return true;
  } catch (error) {
    if (error.response?.status === 412) {
      console.error('Conflict detected. Record was modified by another user.');
    } else {
      console.error('Update failed:', error.response?.data);
    }
    return false;
  }
}

updateWorkOrder('12345', {
  description: 'Replace pump motor - Building A (URGENT)',
  wopriority: 1
});

DELETE -- DELETE

Deleting records is straightforward but carries significant consequences. The API enforces the same referential integrity and business rules as the UI. You cannot delete a work order that has actuals posted against it, for example.

curl:

curl -X DELETE "https://mas-host/maximo/oslc/os/mxwo/12345" \
  -H "apikey: YOUR_API_KEY"

Python:

response = requests.delete(
    f"{BASE_URL}/mxwo/12345",
    headers={"apikey": "YOUR_API_KEY"}
)

if response.status_code == 204:
    print("Record deleted")
elif response.status_code == 400:
    print(f"Cannot delete: {response.json().get('Error', {}).get('message', '')}")

Node.js:

async function deleteWorkOrder(woId) {
  try {
    await axios.delete(`${BASE_URL}/mxwo/${woId}`, {
      headers: { 'apikey': 'YOUR_API_KEY' }
    });
    console.log('Record deleted');
  } catch (error) {
    if (error.response?.status === 400) {
      console.error('Cannot delete:', error.response.data?.Error?.message);
    }
  }
}

Warning: DELETE is permanent. There is no soft-delete in the REST API. If your integration needs to "remove" records, consider changing their status to a closed or cancelled state instead of deleting them.

Advanced Queries with oslc.where

The oslc.where clause is the query engine of the MAS REST API. It supports a rich set of operators that let you build precise queries without over-fetching data. Mastering oslc.where is the difference between an integration that makes 100 API calls and one that makes 5.

Comparison Operators

Operator — Meaning — Example

= — Equal to — status="APPR"

!= — Not equal to — status!="CLOSE"

> — Greater than — wopriority>2

< — Less than — wopriority<3

>= — Greater than or equal — wopriority>=1

<= — Less than or equal — wopriority<=2

Practical Query Examples

Here are ten queries you will use in real integrations, with explanations.

1. Exact match with AND:

oslc.where=siteid="BEDFORD" and status="APPR" and worktype="CM"

All approved corrective maintenance work orders at the BEDFORD site.

2. Wildcard search with %:

oslc.where=description like "%pump%" and siteid="BEDFORD"

All records where the description contains the word "pump." The % wildcard matches any sequence of characters.

3. OR conditions:

oslc.where=status="APPR" or status="INPRG" or status="WMATL"

Work orders in any of three active statuses. Use or to combine multiple conditions when any match is acceptable.

4. Date range queries:

oslc.where=reportdate>="2026-01-01T00:00:00-05:00" and reportdate<"2026-02-01T00:00:00-05:00"

All work orders reported in January 2026. Always include the timezone offset in date values to avoid ambiguity.

5. Querying by related record (asset):

oslc.where=assetnum="PUMP-001" and siteid="BEDFORD"

All work orders for a specific asset at a specific site. Always include siteid when querying by asset -- asset numbers are unique per site, not globally.

6. Null checks:

oslc.where=targstartdate!=null and targcompdate=null

Work orders that have a target start date but no target completion date. Useful for data quality audits.

7. Numeric range:

oslc.where=wopriority>=1 and wopriority<=3 and siteid="BEDFORD"

High and medium priority work orders (priority 1-3).

8. Complex nested conditions:

oslc.where=(status="APPR" or status="INPRG") and wopriority<=2 and worktype="EM"

Emergency work orders that are approved or in progress with high priority. Parentheses group the OR condition so it does not affect the AND clauses.

9. Querying by modification date (for delta syncs):

oslc.where=changedate>="2026-02-05T00:00:00-05:00" and siteid="BEDFORD"

Records modified since a specific date. This is the foundation of delta synchronization -- pull only what changed since your last sync.

10. Combining wildcards with status filters:

oslc.where=description like "%HVAC%" and (status="WAPPR" or status="APPR") and siteid="BEDFORD"

HVAC-related work orders awaiting or already approved.

Field Selection with oslc.select

Do not fetch entire records when you only need three fields. Use oslc.select to reduce response size and improve performance.

oslc.select=wonum,description,status,wopriority,reportdate,assetnum

Selecting child records:

oslc.select=wonum,description,status,wpmaterial{itemnum,itemqty,description}

This returns work orders with only the specified fields from the parent and the nested planned materials.

Sorting with oslc.orderBy

oslc.orderBy=+wopriority,-reportdate

The + prefix sorts ascending (lowest priority number first). The - prefix sorts descending (most recent report date first). You can chain multiple sort fields.

Putting It All Together

Python -- A complete production-style query:

import requests

BASE_URL = "https://mas-host/maximo/oslc/os"
headers = {"apikey": "YOUR_API_KEY", "Accept": "application/json"}

params = {
    "oslc.where": (
        'siteid="BEDFORD" '
        'and (status="APPR" or status="INPRG") '
        'and wopriority<=2 '
        'and reportdate>="2026-01-01T00:00:00-05:00"'
    ),
    "oslc.select": "wonum,description,status,wopriority,reportdate,assetnum,location",
    "oslc.orderBy": "+wopriority,-reportdate",
    "oslc.pageSize": 100
}

response = requests.get(f"{BASE_URL}/mxwo", headers=headers, params=params)
data = response.json()

print(f"Total matching records: {data.get('totalCount', 'unknown')}")
for wo in data.get("member", []):
    print(f"  [{wo.get('wopriority')}] {wo.get('wonum')}: {wo.get('description')}")

Pagination

Never attempt to retrieve all records in a single request. In production environments, queries can match thousands or millions of records. Pagination is not optional -- it is a hard requirement for any integration that will run against real data.

How Pagination Works

The MAS REST API uses oslc.pageSize to control page size and returns a nextPage link in the response when more records are available. You follow the nextPage link until it is no longer present.

Python -- Complete Pagination Implementation

import requests

def get_all_records(base_url, api_key, object_structure, query,
                    select=None, page_size=100):
    """
    Paginate through all results for a given query.

    Args:
        base_url: MAS base URL (e.g., https://mas-host/maximo/oslc/os)
        api_key: API key for authentication
        object_structure: The OS to query (e.g., mxwo, mxasset)
        query: oslc.where clause
        select: Optional oslc.select fields
        page_size: Records per page (default 100, max varies by config)

    Returns:
        List of all matching records
    """
    headers = {"apikey": api_key, "Accept": "application/json"}

    params = {
        "oslc.where": query,
        "oslc.pageSize": page_size
    }
    if select:
        params["oslc.select"] = select

    url = f"{base_url}/{object_structure}"
    all_records = []
    page_count = 0

    while url:
        response = requests.get(url, headers=headers, params=params if page_count == 0 else None)
        response.raise_for_status()
        data = response.json()

        members = data.get("member", [])
        all_records.extend(members)
        page_count += 1

        print(f"  Page {page_count}: fetched {len(members)} records "
              f"(total: {len(all_records)})")

        # Get next page URL from response
        response_info = data.get("responseInfo", {})
        next_page = response_info.get("nextPage", None)

        if next_page:
            url = next_page.get("href") if isinstance(next_page, dict) else None
        else:
            url = None

        # Clear params for subsequent pages -- the nextPage URL includes them
        params = None

    print(f"Pagination complete: {len(all_records)} total records in {page_count} pages")
    return all_records


# Usage
records = get_all_records(
    base_url="https://mas-host/maximo/oslc/os",
    api_key="YOUR_API_KEY",
    object_structure="mxwo",
    query='siteid="BEDFORD" and status="APPR"',
    select="wonum,description,status,wopriority",
    page_size=200
)

Node.js -- Pagination with Async Generators

const axios = require('axios');

async function* paginateRecords(baseUrl, apiKey, objectStructure, query, options = {}) {
  const { select, pageSize = 100 } = options;
  const headers = { 'apikey': apiKey, 'Accept': 'application/json' };

  let url = `${baseUrl}/${objectStructure}`;
  let params = {
    'oslc.where': query,
    'oslc.pageSize': pageSize,
    ...(select && { 'oslc.select': select })
  };
  let isFirstPage = true;

  while (url) {
    const response = await axios.get(url, {
      headers,
      ...(isFirstPage && { params })
    });

    const data = response.data;
    const members = data.member || [];

    // Yield each page of records
    yield members;

    // Follow next page link
    const nextPage = data.responseInfo?.nextPage;
    url = nextPage?.href || null;
    isFirstPage = false;
  }
}

// Usage with async iteration
async function getAllWorkOrders() {
  const allRecords = [];

  for await (const page of paginateRecords(
    'https://mas-host/maximo/oslc/os',
    'YOUR_API_KEY',
    'mxwo',
    'siteid="BEDFORD" and status="APPR"',
    { select: 'wonum,description,status', pageSize: 200 }
  )) {
    allRecords.push(...page);
    console.log(`Fetched page, total records so far: ${allRecords.length}`);
  }

  return allRecords;
}

getAllWorkOrders().then(records => {
  console.log(`Total: ${records.length} work orders`);
});

Pagination Best Practices

  • Set a reasonable page size. 100-200 records per page is the sweet spot for most integrations. Too small means excessive HTTP overhead. Too large means slow response times and memory pressure.
  • Do not use oslc.pageSize without a limit strategy. If you set page size to 5000 and your query matches 500,000 records, you will hammer the server. Consider whether you truly need all records or just a count.
  • Use oslc.orderBy with pagination. Without explicit ordering, the order of records across pages is not guaranteed. If your integration depends on processing records in a specific order, specify it.
  • Implement a safety valve. Set a maximum page count in your pagination loop to prevent runaway queries from consuming resources indefinitely.

Bulk Operations

When you need to create, update, or delete multiple records, individual API calls are slow and wasteful. Bulk operations let you handle many records in a single HTTP request.

Bulk Create

import requests

BASE_URL = "https://mas-host/maximo/oslc/os"
headers = {
    "apikey": "YOUR_API_KEY",
    "Content-Type": "application/json"
}

# Bulk create work orders
bulk_payload = {
    "member": [
        {
            "siteid": "BEDFORD",
            "description": "PM - HVAC Unit 1",
            "worktype": "PM",
            "wopriority": 3
        },
        {
            "siteid": "BEDFORD",
            "description": "PM - HVAC Unit 2",
            "worktype": "PM",
            "wopriority": 3
        },
        {
            "siteid": "BEDFORD",
            "description": "PM - HVAC Unit 3",
            "worktype": "PM",
            "wopriority": 3
        }
    ]
}

response = requests.post(
    f"{BASE_URL}/mxwo?action=system:createbulk",
    headers=headers,
    json=bulk_payload
)

if response.status_code == 200:
    result = response.json()
    print(f"Bulk create successful: {len(result.get('member', []))} records created")
else:
    print(f"Error: {response.status_code} - {response.text}")

Bulk Update

# Bulk update -- change priority on multiple work orders
bulk_update = {
    "member": [
        {
            "href": "https://mas-host/maximo/oslc/os/mxwo/12345",
            "wopriority": 1
        },
        {
            "href": "https://mas-host/maximo/oslc/os/mxwo/12346",
            "wopriority": 1
        },
        {
            "href": "https://mas-host/maximo/oslc/os/mxwo/12347",
            "wopriority": 1
        }
    ]
}

response = requests.post(
    f"{BASE_URL}/mxwo?action=system:updatebulk",
    headers=headers,
    json=bulk_update
)

Node.js -- Bulk Create with Batching

For very large datasets, batch your bulk operations to avoid timeouts:

const axios = require('axios');

async function bulkCreateInBatches(records, batchSize = 50) {
  const BASE_URL = 'https://mas-host/maximo/oslc/os';
  const headers = {
    'apikey': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  };

  const results = { created: 0, failed: 0, errors: [] };

  for (let i = 0; i < records.length; i += batchSize) {
    const batch = records.slice(i, i + batchSize);
    const batchNumber = Math.floor(i / batchSize) + 1;

    try {
      const response = await axios.post(
        `${BASE_URL}/mxwo?action=system:createbulk`,
        { member: batch },
        { headers }
      );
      results.created += batch.length;
      console.log(`Batch ${batchNumber}: ${batch.length} records created`);
    } catch (error) {
      results.failed += batch.length;
      results.errors.push({
        batch: batchNumber,
        error: error.response?.data || error.message
      });
      console.error(`Batch ${batchNumber} failed:`, error.response?.status);
    }
  }

  console.log(`\nBulk create complete: ${results.created} created, ${results.failed} failed`);
  return results;
}

// Generate 200 PM work orders
const workOrders = Array.from({ length: 200 }, (_, i) => ({
  siteid: 'BEDFORD',
  description: `PM - Equipment ${String(i + 1).padStart(3, '0')}`,
  worktype: 'PM',
  wopriority: 3
}));

bulkCreateInBatches(workOrders, 50);

Bulk operation limits: MAS has configurable limits on bulk operation size. The default is typically 100-200 records per request. Exceeding this limit returns a 400 error. Check your environment's mxe.rest.bulkcount system property for the exact limit.

Action Invocations

The MAS REST API does not just handle data -- it can trigger Maximo business actions. Status changes, approvals, and custom automation scripts can all be invoked through the API.

Changing Work Order Status

Status changes in Maximo are not simple field updates. They trigger workflow routing, status validation, and business rules. Use the action parameter to invoke the status change action properly.

curl:

# Change work order status from APPR to INPRG
curl -X POST "https://mas-host/maximo/oslc/os/mxwo/12345?action=wsmethod:changeStatus" \
  -H "apikey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "INPRG",
    "memo": "Work started per dispatcher assignment"
  }'

Python -- Status change with validation:

import requests

def change_wo_status(base_url, api_key, wo_id, new_status, memo=""):
    """
    Change work order status using the proper action invocation.
    This fires all business rules, workflows, and escalations.
    """
    headers = {
        "apikey": api_key,
        "Content-Type": "application/json"
    }

    url = f"{base_url}/mxwo/{wo_id}?action=wsmethod:changeStatus"
    payload = {
        "status": new_status,
        "memo": memo
    }

    response = requests.post(url, headers=headers, json=payload)

    if response.status_code in [200, 204]:
        print(f"Status changed to {new_status}")
        return True
    elif response.status_code == 400:
        error = response.json().get("Error", {})
        print(f"Status change rejected: {error.get('message', 'Unknown error')}")
        return False
    else:
        print(f"Unexpected error: {response.status_code}")
        return False


# Usage
change_wo_status(
    base_url="https://mas-host/maximo/oslc/os",
    api_key="YOUR_API_KEY",
    wo_id="12345",
    new_status="INPRG",
    memo="Technician en route"
)

Approving a Purchase Order

def approve_purchase_order(base_url, api_key, po_id):
    """Approve a PO via API action invocation."""
    headers = {
        "apikey": api_key,
        "Content-Type": "application/json"
    }

    url = f"{base_url}/mxpo/{po_id}?action=wsmethod:changeStatus"
    payload = {
        "status": "APPR",
        "memo": "Approved via automated integration"
    }

    response = requests.post(url, headers=headers, json=payload)
    return response.status_code in [200, 204]

Invoking Custom Automation Scripts

If you have automation scripts configured with action launch points, you can invoke them through the API:

# Invoke a custom action defined as an automation script
def invoke_custom_action(base_url, api_key, wo_id, action_name, params=None):
    """
    Invoke a custom automation script action on a work order.

    The action_name must match the action configured in the
    automation script's action launch point.
    """
    headers = {
        "apikey": api_key,
        "Content-Type": "application/json"
    }

    url = f"{base_url}/mxwo/{wo_id}?action=wsmethod:{action_name}"

    response = requests.post(url, headers=headers, json=params or {})

    if response.status_code in [200, 204]:
        print(f"Action '{action_name}' executed successfully")
        return True
    else:
        print(f"Action failed: {response.status_code} - {response.text}")
        return False


# Example: invoke a custom "assignCrew" action
invoke_custom_action(
    base_url="https://mas-host/maximo/oslc/os",
    api_key="YOUR_API_KEY",
    wo_id="12345",
    action_name="assignCrew",
    params={"crewid": "CREW-ALPHA", "schedstart": "2026-02-10T08:00:00-05:00"}
)

Important: Action invocations execute the same business logic as performing the action through the UI. If a workflow requires approval before a status change, the API call will either route to the workflow or reject the change -- depending on the workflow configuration. The API does not bypass business rules.

Attachments

Maximo's DOCLINKS system manages file attachments for records. The REST API supports uploading, downloading, and listing attachments on any record that supports document links.

Uploading an Attachment

Python -- Upload a file to a work order:

import requests

def upload_attachment(base_url, api_key, wo_href, file_path,
                      description, doc_type="Attachments"):
    """
    Upload a file attachment to a work order.

    Args:
        base_url: MAS base URL
        api_key: API key
        wo_href: Full href of the work order resource
        file_path: Local path to the file
        description: Description of the attachment
        doc_type: Document type (default: Attachments)
    """
    import os
    import mimetypes

    filename = os.path.basename(file_path)
    mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"

    headers = {
        "apikey": api_key,
        "slug": filename,
        "x-document-meta": f"FILE/{doc_type}",
        "x-document-description": description
    }

    with open(file_path, "rb") as f:
        response = requests.post(
            f"{wo_href}/doclinks",
            headers={**headers, "Content-Type": mime_type},
            data=f
        )

    if response.status_code == 201:
        print(f"Uploaded '{filename}' successfully")
        return response.headers.get("Location")
    else:
        print(f"Upload failed: {response.status_code} - {response.text}")
        return None


# Usage
upload_attachment(
    base_url="https://mas-host/maximo/oslc/os",
    api_key="YOUR_API_KEY",
    wo_href="https://mas-host/maximo/oslc/os/mxwo/12345",
    file_path="/path/to/inspection_report.pdf",
    description="Q1 2026 Pump Inspection Report"
)

Listing Attachments

def list_attachments(base_url, api_key, wo_href):
    """List all attachments on a work order."""
    headers = {"apikey": api_key, "Accept": "application/json"}

    response = requests.get(f"{wo_href}/doclinks", headers=headers)
    data = response.json()

    for doc in data.get("member", []):
        print(f"  - {doc.get('describedBy', {}).get('fileName', 'Unknown')}: "
              f"{doc.get('describedBy', {}).get('description', 'No description')}")

    return data.get("member", [])

Downloading an Attachment

def download_attachment(api_key, attachment_href, save_path):
    """Download an attachment by its href."""
    headers = {"apikey": api_key}

    response = requests.get(attachment_href, headers=headers, stream=True)

    if response.status_code == 200:
        with open(save_path, "wb") as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)
        print(f"Downloaded to {save_path}")
    else:
        print(f"Download failed: {response.status_code}")


# Usage: List then download
attachments = list_attachments(
    base_url="https://mas-host/maximo/oslc/os",
    api_key="YOUR_API_KEY",
    wo_href="https://mas-host/maximo/oslc/os/mxwo/12345"
)

if attachments:
    download_attachment(
        api_key="YOUR_API_KEY",
        attachment_href=attachments[0]["href"],
        save_path="/tmp/downloaded_report.pdf"
    )

Error Handling and Retry Logic

Production integrations fail. Networks drop. Servers restart. Rate limits trigger. The difference between a fragile integration and a resilient one is how it handles these failures.

The Resilient API Client

Python -- Production-grade API client with retry logic:

import time
import logging
from requests.exceptions import RequestException, ConnectionError, Timeout
import requests

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("maximo_api")


def resilient_api_call(method, url, headers, max_retries=3,
                       timeout=30, **kwargs):
    """
    Make an API call with exponential backoff retry logic.

    Handles:
    - Network errors (ConnectionError, Timeout)
    - Rate limiting (429)
    - Server errors (500, 502, 503, 504)
    - Transient failures

    Does NOT retry:
    - Client errors (400, 401, 403, 404, 409) -- these need human intervention
    """
    retryable_status_codes = {429, 500, 502, 503, 504}

    for attempt in range(max_retries):
        try:
            response = requests.request(
                method, url, headers=headers, timeout=timeout, **kwargs
            )

            # Success
            if response.status_code < 400:
                return response

            # Rate limited -- respect Retry-After header if present
            if response.status_code == 429:
                retry_after = response.headers.get("Retry-After")
                wait = int(retry_after) if retry_after else (2 ** attempt * 5)
                logger.warning(f"Rate limited. Waiting {wait}s before retry "
                             f"(attempt {attempt + 1}/{max_retries})")
                time.sleep(wait)
                continue

            # Server error -- retry with backoff
            if response.status_code in retryable_status_codes:
                wait = 2 ** attempt * 2
                logger.warning(f"Server error {response.status_code}. "
                             f"Waiting {wait}s (attempt {attempt + 1}/{max_retries})")
                time.sleep(wait)
                continue

            # Client error -- do not retry, raise immediately
            logger.error(f"Client error {response.status_code}: {response.text[:500]}")
            response.raise_for_status()

        except (ConnectionError, Timeout) as e:
            if attempt == max_retries - 1:
                logger.error(f"All {max_retries} attempts failed. Last error: {e}")
                raise
            wait = 2 ** attempt * 2
            logger.warning(f"Connection error: {e}. "
                         f"Waiting {wait}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(wait)

        except RequestException as e:
            logger.error(f"Unexpected request error: {e}")
            raise

    # Should not reach here, but just in case
    raise Exception(f"API call failed after {max_retries} attempts")


# Usage
headers = {"apikey": "YOUR_API_KEY", "Accept": "application/json"}

# GET with retry
response = resilient_api_call(
    "GET",
    "https://mas-host/maximo/oslc/os/mxwo?oslc.pageSize=10",
    headers=headers
)
print(response.json())

# POST with retry
response = resilient_api_call(
    "POST",
    "https://mas-host/maximo/oslc/os/mxwo",
    headers={**headers, "Content-Type": "application/json"},
    json={"siteid": "BEDFORD", "description": "Test WO", "worktype": "CM"}
)

Node.js -- Resilient Client with axios-retry

const axios = require('axios');
const axiosRetry = require('axios-retry').default;

// Configure axios instance with retry logic
const maximoClient = axios.create({
  baseURL: 'https://mas-host/maximo/oslc/os',
  timeout: 30000,
  headers: {
    'apikey': 'YOUR_API_KEY',
    'Accept': 'application/json'
  }
});

// Configure retry behavior
axiosRetry(maximoClient, {
  retries: 3,
  retryDelay: (retryCount) => {
    const delay = Math.pow(2, retryCount) * 1000;
    console.log(`Retry attempt ${retryCount}, waiting ${delay}ms`);
    return delay;
  },
  retryCondition: (error) => {
    // Retry on network errors and specific status codes
    return (
      axiosRetry.isNetworkOrIdempotentRequestError(error) ||
      error.response?.status === 429 ||
      error.response?.status >= 500
    );
  }
});

// Usage
async function getWorkOrders() {
  try {
    const response = await maximoClient.get('/mxwo', {
      params: {
        'oslc.where': 'siteid="BEDFORD" and status="APPR"',
        'oslc.pageSize': 50
      }
    });
    return response.data;
  } catch (error) {
    console.error('All retries exhausted:', error.message);
    throw error;
  }
}

Common HTTP Status Codes in MAS Context

Understanding what each status code means in the Maximo context saves hours of debugging.

Status Code — Meaning — MAS Context

200 OK — Request succeeded — Record(s) returned successfully, action completed

201 Created — Resource created — New record created; check Location header for URI

204 No Content — Success, no body — Update or delete completed successfully

400 Bad Request — Invalid request — Missing required field, validation rule failed, invalid query syntax

401 Unauthorized — Authentication failed — Invalid or expired API key/token

403 Forbidden — Authorization failed — User lacks permission for this object/site/action

404 Not Found — Resource not found — Record does not exist or object structure is wrong

405 Method Not Allowed — HTTP method not supported — Trying DELETE on a read-only object structure

409 Conflict — Conflict detected — Duplicate key violation (e.g., creating a record with an existing ID)

412 Precondition Failed — ETag mismatch — Record was modified since you last read it (optimistic locking)

429 Too Many Requests — Rate limit exceeded — Slow down; implement backoff

500 Internal Server Error — Server-side failure — Maximo application error; check server logs

502 Bad Gateway — Gateway error — Load balancer or reverse proxy issue

503 Service Unavailable — Server overloaded — MAS pod is restarting or under heavy load

Parsing Error Responses

MAS error responses follow a consistent JSON structure. Here is how to extract useful information:

def parse_maximo_error(response):
    """
    Parse a MAS error response and return a structured error object.
    """
    try:
        error_body = response.json()
        error_info = error_body.get("Error", {})

        return {
            "status_code": response.status_code,
            "error_code": error_info.get("reasonCode", "UNKNOWN"),
            "message": error_info.get("message", "No message provided"),
            "status_description": error_info.get("statusDescription", ""),
            "extended_error": error_info.get("extendedError", {})
        }
    except ValueError:
        return {
            "status_code": response.status_code,
            "error_code": "PARSE_ERROR",
            "message": response.text[:500],
            "status_description": "",
            "extended_error": {}
        }


# Usage
response = requests.post(url, headers=headers, json=bad_payload)
if response.status_code >= 400:
    error = parse_maximo_error(response)
    print(f"[{error['error_code']}] {error['message']}")

Performance Tips

The difference between an integration that runs in 30 seconds and one that runs in 30 minutes often comes down to these optimizations.

1. Use oslc.select to Minimize Response Size

Every field you do not need is wasted bandwidth and processing time. A full work order record with all related objects can be 50+ KB. Selecting only the fields you need can reduce that to 1-2 KB.

# Bad: fetches everything
GET /maximo/oslc/os/mxwo?oslc.where=siteid="BEDFORD"

# Good: fetches only what you need
GET /maximo/oslc/os/mxwo?oslc.where=siteid="BEDFORD"&oslc.select=wonum,status,description

2. Right-Size Your Page Size

Scenario — Recommended Page Size

Interactive UI queries — 20-50

Background sync jobs — 100-200

Bulk data export — 500 (if your server config allows)

Record count only — Use oslc.pageSize=1 with totalCount

3. Use Connection Pooling

Creating a new HTTP connection for every request adds 50-200ms of overhead. Use connection pooling with persistent sessions.

Python:

import requests

# Create a session that reuses connections
session = requests.Session()
session.headers.update({
    "apikey": "YOUR_API_KEY",
    "Accept": "application/json"
})

# All requests through the session reuse the TCP connection
response1 = session.get("https://mas-host/maximo/oslc/os/mxwo?oslc.pageSize=10")
response2 = session.get("https://mas-host/maximo/oslc/os/mxasset?oslc.pageSize=10")
response3 = session.get("https://mas-host/maximo/oslc/os/mxsr?oslc.pageSize=10")

Node.js:

const https = require('https');
const axios = require('axios');

// Create an axios instance with a keep-alive agent
const agent = new https.Agent({
  keepAlive: true,
  maxSockets: 10
});

const client = axios.create({
  baseURL: 'https://mas-host/maximo/oslc/os',
  httpsAgent: agent,
  headers: { 'apikey': 'YOUR_API_KEY', 'Accept': 'application/json' }
});

4. Implement Delta Synchronization

Do not pull all records every time. Track the last sync timestamp and query only records that changed since then.

from datetime import datetime, timezone

def delta_sync(base_url, api_key, object_structure, last_sync_time):
    """
    Fetch only records modified since last sync.

    Args:
        last_sync_time: ISO 8601 timestamp of last successful sync
    """
    headers = {"apikey": api_key, "Accept": "application/json"}

    query = f'changedate>="{last_sync_time}"'
    params = {
        "oslc.where": query,
        "oslc.orderBy": "+changedate",
        "oslc.pageSize": 200
    }

    response = requests.get(
        f"{base_url}/{object_structure}",
        headers=headers,
        params=params
    )

    data = response.json()
    records = data.get("member", [])

    # Update sync timestamp to now
    new_sync_time = datetime.now(timezone.utc).isoformat()

    return records, new_sync_time

5. Cache Reference Data

Data that rarely changes -- locations, item masters, classification structures -- should be cached locally rather than queried on every transaction.

import time

class MaximoCache:
    """Simple TTL cache for Maximo reference data."""

    def __init__(self, ttl_seconds=3600):
        self._cache = {}
        self._ttl = ttl_seconds

    def get(self, key):
        if key in self._cache:
            data, timestamp = self._cache[key]
            if time.time() - timestamp < self._ttl:
                return data
            del self._cache[key]
        return None

    def set(self, key, data):
        self._cache[key] = (data, time.time())


# Usage
cache = MaximoCache(ttl_seconds=3600)  # 1-hour TTL

def get_locations(base_url, api_key, siteid):
    cache_key = f"locations_{siteid}"
    cached = cache.get(cache_key)
    if cached:
        return cached

    headers = {"apikey": api_key, "Accept": "application/json"}
    response = requests.get(
        f"{base_url}/mxloc",
        headers=headers,
        params={"oslc.where": f'siteid="{siteid}"', "oslc.pageSize": 1000}
    )
    locations = response.json().get("member", [])
    cache.set(cache_key, locations)
    return locations

6. Use Lean Mode for Minimal Responses

Add the lean parameter to reduce response payload by removing namespace prefixes and metadata:

GET /maximo/oslc/os/mxwo?lean=1&oslc.pageSize=10

This can reduce response size by 20-40% for large result sets.

Quick Reference Card

Bookmark this section. It contains the URL patterns and parameters you will use most often.

URL Patterns

Operation — HTTP Method — URL Pattern

List / Query — GET — /maximo/oslc/os/{os}?oslc.where=...

Get by ID — GET — /maximo/oslc/os/{os}/{id}

Create — POST — /maximo/oslc/os/{os}

Update — POST + x-method-override — /maximo/oslc/os/{os}/{id}

Delete — DELETE — /maximo/oslc/os/{os}/{id}

Bulk Create — POST — /maximo/oslc/os/{os}?action=system:createbulk

Bulk Update — POST — /maximo/oslc/os/{os}?action=system:updatebulk

Change Status — POST — /maximo/oslc/os/{os}/{id}?action=wsmethod:changeStatus

Custom Action — POST — /maximo/oslc/os/{os}/{id}?action=wsmethod:{action}

List Attachments — GET — /maximo/oslc/os/{os}/{id}/doclinks

Upload Attachment — POST — /maximo/oslc/os/{os}/{id}/doclinks

Query Parameters

Parameter — Purpose — Example

oslc.where — Filter records — status="APPR" and siteid="BEDFORD"

oslc.select — Choose fields — wonum,description,status

oslc.orderBy — Sort results — +wopriority,-reportdate

oslc.pageSize — Page size — 100

lean — Minimize response — 1

action — Invoke action — wsmethod:changeStatus

Required Headers

Header — Purpose — Example Value

apikey — API key auth — a1b2c3d4e5f6g7h8i9j0

Authorization — OAuth auth — Bearer eyJhbGciOi...

Content-Type — Request body format — application/json

Accept — Response format — application/json

x-method-override — Override HTTP method — PATCH

If-Match — Optimistic locking — ETag value from GET

slug — Attachment filename — report.pdf

x-document-meta — Attachment doc type — FILE/Attachments

Key Takeaways

  1. API key authentication is the simplest method for server-to-server integrations. OAuth 2.0 is the right choice when you need token expiration, user context, or compliance with enterprise security policies.
  2. The oslc.where clause is your query engine. It supports comparisons, wildcards, AND/OR logic, date ranges, and null checks. Master it and you will eliminate most unnecessary API calls.
  3. Bulk operations are essential for performance. Creating 100 work orders one at a time means 100 HTTP round trips. A single bulk create request does the same work in one call.
  4. Always implement pagination, error handling, and retry logic in production integrations. The happy path works in development. Production requires resilience.
  5. The REST API enforces business logic. Validation rules, workflows, escalations, and security -- everything that fires through the UI fires through the API. This is a feature, not a limitation. It means your integrations maintain data integrity by default.
  6. Performance optimization is not premature. Using oslc.select, connection pooling, delta synchronization, and caching from the start prevents the painful refactoring that comes when your integration hits production data volumes.

References

Series Navigation

Previous:Part 3 -- From Publish Channels to Events: The Event-Driven Transformation

Next:Part 5 -- Enterprise Integration Patterns: App Connect, Kafka, and Beyond

View the full MAS INTEGRATION series index

Part 4 of the "MAS INTEGRATION" series | Published by TheMaximoGuys