> ## Documentation Index
> Fetch the complete documentation index at: https://docs.coreflux.org/llms.txt
> Use this file to discover all available pages before exploring further.

# ServiceMax (PTC)

> Client-mode REST API connector for ServiceMax (PTC) on Salesforce: create and close ServiceMax work orders from alarms, list open work orders by technician or installed product, find installed products by serial and patch them with telemetry, read technicians, open Cases, publish Platform Events, and write Asset 360 technical attributes, through the Salesforce REST API with OAuth 2.0

Technical Preview

## Overview

[ServiceMax](https://www.servicemax.com/) (part of PTC) is field service management for equipment makers and operators. **ServiceMax Core** is a managed package (`SVMXC__` objects) and **Asset 360** (`SVMXA360__`) builds on Salesforce Field Service; both run inside your Salesforce org. This connector lets the broker open a ServiceMax work order when a machine raises an alarm, put run hours and other telemetry on the installed product, and show each technician's open work orders, so service teams see what the machine sees.

The template has two connectors:

* **ServiceMaxApi** (`ServicemaxRestApi.lot`), a client connector on your org's My Domain with 26 events: 10 writes, 14 reads and 2 token events. It has no `DELETE` events. Events starting with `A360` need Asset 360; the others need ServiceMax Core.
* **ServiceMaxNotificationReceiver** (`cmms_servicemax_webhook.lot`), an optional server connector on port `9011` that receives JSON callouts from a Flow or Apex in your org and publishes them on MQTT.

> [!NOTE]
>
> ServiceMax publishes no OpenAPI or Postman file of its own: its objects are ordinary Salesforce sObjects, read and written with the Salesforce REST API. The events were picked by hand from the PTC data dictionaries ([Work Order](https://support.ptc.com/help/servicemaxcore/en/articles/core/work-order1.html), [Installed Product](https://support.ptc.com/help/servicemaxcore/en/articles/core/installed-product-2.html), [Technician/Equipment](https://support.ptc.com/help/servicemaxcore/en/articles/core/technician-equipment.html)) and the [Salesforce REST API Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/).

## Events

| Event | Topic | Request | Kind | What it does |
| - | - | - | - | - |
| `GetToken` | `cmms/servicemax/token/create` | `POST /services/oauth2/token` | Auth | Get an access token from your My Domain (form body: client\_credentials, or password / JWT bearer / refresh\_token grants). Reply access\_token + instance\_url; send access\_token on every other event |
| `RevokeToken` | `cmms/servicemax/token/revoke` | `POST /services/oauth2/revoke` | Auth | Revoke an access or refresh token (form body token=...). Not a data delete |
| `ListApiVersions` | `cmms/servicemax/api-version/list` | `GET /services/data/` | Read | API versions the org serves (no auth needed). The connector pins v65.0 |
| `GetLimits` | `cmms/servicemax/limit/get` | `GET /services/data/v65.0/limits` | Read | Org limits (DailyApiRequests Max / Remaining and more). Check before polling |
| `Query` | `cmms/servicemax/soql/query` | `GET /services/data/v65.0/query?q={q}` | Read | Any SOQL query, URL-encoded (spaces as +). Up to 2000 rows per page; nextRecordsUrl when done is false |
| `QueryMore` | `cmms/servicemax/soql/next` | `GET /services/data/v65.0/query/{id}` | Read | Next page of a query (the part of nextRecordsUrl after /query/) |
| `DescribeWorkOrder` | `cmms/servicemax/work-order-describe/get` | `GET /services/data/v65.0/sobjects/SVMXC__Service_Order__c/describe` | Read | Field list and the org's picklist values for SVMXC\_\_Order\_Status\_\_c, SVMXC\_\_Priority\_\_c and SVMXC\_\_Order\_Type\_\_c (they are configurable) |
| `GetWorkOrder` | `cmms/servicemax/work-order/get` | `GET /services/data/v65.0/sobjects/SVMXC__Service_Order__c/{id}?{query}` | Read | One ServiceMax work order by record Id (query fields=... or "" for all) |
| `ListOpenWorkOrdersByTechnician` | `cmms/servicemax/work-order/list-open-by-technician` | `GET /services/data/v65.0/query?q=<SOQL>` | Read | Open work orders assigned to one technician (SVMXC\_\_Group\_Member\_\_c, not closed), next scheduled first, 200 max |
| `ListOpenWorkOrdersByInstalledProduct` | `cmms/servicemax/work-order/list-open-by-installed-product` | `GET /services/data/v65.0/query?q=<SOQL>` | Read | Open work orders for one installed product (check before opening a duplicate from a repeating alarm) |
| `CreateWorkOrder` | `cmms/servicemax/work-order/create` | `POST /services/data/v65.0/sobjects/SVMXC__Service_Order__c` | Write | Create a ServiceMax work order from an alarm; reply 201 `{id, success, errors}` |
| `UpdateWorkOrder` | `cmms/servicemax/work-order/update` | `PATCH /services/data/v65.0/sobjects/SVMXC__Service_Order__c/{id}` | Write | Change work order fields (status, priority, technician SVMXC\_\_Group\_Member\_\_c, ...); reply 204 empty |
| `UpsertWorkOrderByExternalId` | `cmms/servicemax/work-order/upsert` | `PATCH /services/data/v65.0/sobjects/SVMXC__Service_Order__c/{external_id_field}/{external_id}` | Write | Create or update a work order keyed by your own External ID field (one work order per alarm id). 201 created / 200 updated |
| `DescribeInstalledProduct` | `cmms/servicemax/installed-product-describe/get` | `GET /services/data/v65.0/sobjects/SVMXC__Installed_Product__c/describe` | Read | Installed product fields, including your custom telemetry fields and SVMXC\_\_Status\_\_c values |
| `GetInstalledProduct` | `cmms/servicemax/installed-product/get` | `GET /services/data/v65.0/sobjects/SVMXC__Installed_Product__c/{id}?{query}` | Read | One installed product by record Id |
| `FindInstalledProductBySerial` | `cmms/servicemax/installed-product/find-by-serial` | `GET /services/data/v65.0/query?q=<SOQL>` | Read | Map a machine serial number from MQTT to its installed product Id |
| `UpdateInstalledProduct` | `cmms/servicemax/installed-product/update` | `PATCH /services/data/v65.0/sobjects/SVMXC__Installed_Product__c/{id}` | Write | Telemetry on the installed product: your custom fields (e.g. Run\_Hours\_\_c) plus SVMXC\_\_Status\_\_c / SVMXC\_\_Latitude\_\_c / SVMXC\_\_Longitude\_\_c; reply 204 |
| `UpdateRecordsBatch` | `cmms/servicemax/record-batch/update` | `PATCH /services/data/v65.0/composite/sobjects` | Write | Update up to 200 records in one call (sObject Collections), e.g. run hours for a whole line; reply per-record success / errors |
| `ListTechnicians` | `cmms/servicemax/technician/list` | `GET /services/data/v65.0/query?q=<SOQL>` | Read | Active technicians / equipment (SVMXC\_\_Service\_Group\_Members\_\_c) with service team and Salesforce user |
| `GetTechnician` | `cmms/servicemax/technician/get` | `GET /services/data/v65.0/sobjects/SVMXC__Service_Group_Members__c/{id}?{query}` | Read | One technician / equipment record by Id |
| `CreateCase` | `cmms/servicemax/case/create` | `POST /services/data/v65.0/sobjects/Case` | Write | Open a Salesforce Case (the usual ServiceMax entry point before a work order is dispatched) |
| `PublishPlatformEvent` | `cmms/servicemax/platform-event/publish` | `POST /services/data/v65.0/sobjects/{event_name}` | Write | Publish a platform event you defined (e.g. Coreflux\_Telemetry\_\_e) for Flows / Apex triggers to act on |
| `A360SaveTechnicalAttributes` | `cmms/servicemax/a360-technical-attribute/save` | `POST /services/apexrest/SVMXA360/technicalAttribute/v1/saveTechnicalAttributes/` | Write | Asset 360 only: write telemetry as technical attributes (up to 200 entries, with time of occurrence) on Assets |
| `A360CreateWorkOrder` | `cmms/servicemax/a360-work-order/create` | `POST /services/data/v65.0/sobjects/WorkOrder` | Write | Asset 360 only: create a standard WorkOrder on an Asset |
| `A360ListOpenWorkOrdersByAsset` | `cmms/servicemax/a360-work-order/list-open-by-asset` | `GET /services/data/v65.0/query?q=<SOQL>` | Read | Asset 360 only: open standard WorkOrders for one Asset |
| `A360UpdateAsset` | `cmms/servicemax/a360-asset/update` | `PATCH /services/data/v65.0/sobjects/Asset/{id}` | Write | Asset 360 only: update a standard Asset (status or your custom telemetry fields) |

Replies arrive on the same topic with `/response` appended. The SOQL behind the fixed query events:

* `ListOpenWorkOrdersByTechnician`:

  ```sql theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
  SELECT Id,Name,SVMXC__Order_Status__c,SVMXC__Priority__c,SVMXC__Order_Type__c,SVMXC__Component__c,SVMXC__Company__c,SVMXC__Scheduled_Date_Time__c,SVMXC__Problem_Description__c FROM SVMXC__Service_Order__c WHERE SVMXC__Group_Member__c='{value.json.technician_id}' AND SVMXC__Closed_On__c=null ORDER BY SVMXC__Scheduled_Date_Time__c LIMIT 200
  ```
* `ListOpenWorkOrdersByInstalledProduct`:

  ```sql theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
  SELECT Id,Name,SVMXC__Order_Status__c,SVMXC__Priority__c,SVMXC__Order_Type__c,SVMXC__Component__c,SVMXC__Company__c,SVMXC__Scheduled_Date_Time__c,SVMXC__Problem_Description__c FROM SVMXC__Service_Order__c WHERE SVMXC__Component__c='{value.json.installed_product_id}' AND SVMXC__Closed_On__c=null LIMIT 200
  ```
* `FindInstalledProductBySerial`:

  ```sql theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
  SELECT Id,Name,SVMXC__Serial_Lot_Number__c,SVMXC__Status__c,SVMXC__Product__c,SVMXC__Company__c,SVMXC__Site__c,SVMXC__Parent__c FROM SVMXC__Installed_Product__c WHERE SVMXC__Serial_Lot_Number__c='{value.json.serial}' LIMIT 5
  ```
* `ListTechnicians`:

  ```sql theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
  SELECT Id,Name,SVMXC__Service_Group__c,SVMXC__Salesforce_User__c,SVMXC__Email__c,SVMXC__Active__c FROM SVMXC__Service_Group_Members__c WHERE SVMXC__Active__c=true LIMIT 200
  ```
* `A360ListOpenWorkOrdersByAsset`:

  ```sql theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
  SELECT Id,WorkOrderNumber,Status,Priority,Subject,AssetId,OwnerId FROM WorkOrder WHERE AssetId='{value.json.asset_id}' AND IsClosed=false LIMIT 200
  ```

## Prerequisites

* A Salesforce org with ServiceMax Core installed (and Asset 360 for the `A360` events).
* An **External Client App** (or Connected App) with OAuth enabled. For server-to-server use enable the **client credentials flow** and set a **Run As** integration user that has the ServiceMax permission sets and access to the objects and fields you write.
* Your org's **My Domain** URL, for example `https://acme.my.salesforce.com` (sandbox `https://acme--uat.sandbox.my.salesforce.com`). Change `BASE_ADDRESS` to it. `login.salesforce.com` and `test.salesforce.com` only issue tokens; REST calls on them fail with `URL_NOT_RESET`.
* Outbound HTTPS from the broker to that host.
* For notifications only: a public HTTPS URL that forwards to the broker's port `9011`.

## Authentication

> [!WARNING]
>
> The consumer secret and every access token act as the Run As user. Store the consumer key and secret in the Hub secrets `SALESFORCE_CLIENT_ID` and `SALESFORCE_CLIENT_SECRET`, read them with `GET SECRET`, and never put them or a token in a connector file or a retained topic.

`GetToken` posts a form body to `/services/oauth2/token` on your My Domain. With the client credentials flow:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "form": "grant_type=client_credentials&client_id=<Consumer Key (Hub secret SALESFORCE_CLIENT_ID)>&client_secret=<Consumer Secret (Hub secret SALESFORCE_CLIENT_SECRET)>"
}
```

The reply carries `access_token` and `instance_url`. Every other event sends `Authorization: Bearer {value.json.access_token}`, so each payload carries the token:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access_token from GetToken>"
}
```

The same event works for the JWT bearer flow (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<signed JWT>`) and refresh tokens. Tokens expire with the org's session timeout; on `401 INVALID_SESSION_ID` call `GetToken` again. The example actions refresh it every hour.

Answers seen from the real Salesforce hosts without valid credentials: a fake client returns `400 {"error":"invalid_client_id"}`, a fake JWT `400 invalid_grant`, and any REST call with a fake token `401 [{"errorCode":"INVALID_SESSION_ID"}]`.

## Payload conventions

* `access_token`: the token from `GetToken`.
* `id`: an 18-character Salesforce record Id (work order `a2x...`, installed product, technician, Asset), or the query locator for `QueryMore`.
* `query`: `fields=Id,Name,...` for the get events, or `""` for all fields.
* `q`: a whole SOQL statement, URL-encoded (spaces as `+`).
* `technician_id`, `installed_product_id`, `serial`, `asset_id`: values the fixed queries put inside SOQL quotes.
* `body`: the JSON record, sent unchanged on `POST` and `PATCH` events.

Creates answer `201` with `{"id": "...", "success": true, "errors": []}`; updates answer an empty `204`. Errors are JSON arrays such as `[{"errorCode": "REQUIRED_FIELD_MISSING", "message": "...", "fields": [...]}]`.

> [!WARNING]
>
> `technician_id`, `installed_product_id`, `serial` and `asset_id` are inserted into SOQL as they are. Pass Salesforce Ids or serial numbers that come from your own system, never raw user text with quotes in it.

Create a work order from an alarm. `SVMXC__Component__c` is the installed product, `SVMXC__Company__c` the account:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access_token from GetToken>",
  "body": {
    "SVMXC__Company__c": "<Account Id>",
    "SVMXC__Component__c": "<installed product Id>",
    "SVMXC__Order_Type__c": "Field Service",
    "SVMXC__Priority__c": "High",
    "SVMXC__Problem_Description__c": "Alarm TT-101 = 92.4 C at 2026-10-06T06:15:00Z (Coreflux)"
  }
}
```

Close it. `SVMXC__Order_Status__c`, `SVMXC__Priority__c` and `SVMXC__Order_Type__c` are picklists that each org configures, so read the allowed values with `DescribeWorkOrder` first:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access_token from GetToken>",
  "id": "<work order Id>",
  "body": {
    "SVMXC__Order_Status__c": "Closed"
  }
}
```

To avoid one work order per repeated alarm, add a custom External ID field (for example `Coreflux_Alarm_Id__c`) to the work order and use `UpsertWorkOrderByExternalId`, or check `ListOpenWorkOrdersByInstalledProduct` first.

## Telemetry

ServiceMax Core has no standard telemetry field on `SVMXC__Installed_Product__c`. Add the custom fields you need (for example `Run_Hours__c` and `Last_Telemetry_At__c`) and write them with `UpdateInstalledProduct`, or for many machines at once with `UpdateRecordsBatch` (up to 200 records per call). Map a machine's serial number to its record with `FindInstalledProductBySerial`.

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access_token from GetToken>",
  "id": "<installed product Id>",
  "body": {
    "Run_Hours__c": 1287.5,
    "Last_Telemetry_At__c": "2026-10-06T06:30:00Z",
    "SVMXC__Latitude__c": 41.1579,
    "SVMXC__Longitude__c": -8.6291
  }
}
```

On **Asset 360**, send readings as technical attributes instead. `A360SaveTechnicalAttributes` creates missing attributes, records the time of each reading and finds the Asset by Id, name or an identifier field (200 entries per call):

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access_token from GetToken>",
  "body": {
    "source": "External System",
    "objectName": "Asset",
    "assetIdentifierFieldAPIName": "SerialNumber",
    "attributeList": [
      {
        "value": "10800",
        "unit": "Watt",
        "attributeKey": "EnergyConsumption",
        "dataType": "Number",
        "timeOfOccurence": "2026-10-06 06:30:00",
        "assetIdentifiers": [
          {
            "identifierName": "Serial Number",
            "value": "SN-PRESS-0042"
          }
        ]
      }
    ]
  }
}
```

## Notification receiver

Deploy `cmms_servicemax_webhook.lot` only if you want the org to push changes to the broker. It opens HTTP port `9011`.

| Event | Request | Published on | What it receives |
| - | - | - | - |
| `NotifyWorkOrders` | `POST /servicemax/notify/work-orders` | `cmms/servicemax/work-order-notification/received` | Flow HTTP Callout / Apex callout with JSON when a work order is created or changes (POST https\://`<public host>`/servicemax/notify/work-orders) |
| `NotifyInstalledProducts` | `POST /servicemax/notify/installed-products` | `cmms/servicemax/installed-product-notification/received` | Same for installed product / Asset changes |
| `NotifyEvents` | `POST /servicemax/notify/events` | `cmms/servicemax/notification/received` | Any other JSON callout (cases, Asset 360 objects). Filter on the body in a LoT action |

Salesforce has no built-in JSON webhook. Build a record-triggered Flow with an **HTTP Callout** action (or an Apex callout) on `SVMXC__Service_Order__c` or `SVMXC__Installed_Product__c`, point a Named Credential at the public URL of the matching path, and send the fields you need as JSON.

> [!WARNING]
>
> The receiver connector does not authenticate callers. Put a reverse proxy with a public TLS certificate in front of port `9011` and check a shared secret header that the Named Credential sends. Outbound Messages are SOAP and expect an `<Ack>true</Ack>` reply, which this connector does not send, so they are not supported.

## Connector

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
# Coreflux REST_API template — ServiceMax (PTC) on the Salesforce REST API v65.0 (beta): get an OAuth token, create
#   ServiceMax Core work orders (SVMXC__Service_Order__c) from alarms, upsert them by your own alarm id, list open work
#   orders by technician (SVMXC__Group_Member__c) or installed product, close them, find installed products
#   (SVMXC__Installed_Product__c) by serial number and PATCH them with telemetry (single or 200-record batch), read
#   technicians (SVMXC__Service_Group_Members__c), open Cases, publish Platform Events, and for Asset 360 orgs write
#   technical attributes, create standard WorkOrders and update Assets.
# Hugo catalog #170, Tier 3 (cmms). Sources: PTC ServiceMax Core and Asset 360 data dictionaries (support.ptc.com) and
#   the Salesforce REST API Developer Guide (cached in openapi-cache/servicemax/; ServiceMax publishes no OpenAPI or
#   Postman file). Credentials are placeholders only.
# Docs: https://docs.coreflux.org/latest/lot-language/routes/rest-api-apps/servicemax
#
# Host: your Salesforce org's My Domain, https://YOUR_DOMAIN.my.salesforce.com (sandbox:
#   https://YOUR_DOMAIN--SANDBOX.sandbox.my.salesforce.com). Use the instance_url from the token reply. REST calls on
#   login.salesforce.com / test.salesforce.com answer 400 URL_NOT_RESET; those hosts only issue tokens.
# Auth: Salesforce OAuth 2.0 through an External Client App / Connected App. GetToken posts a form body
#   ({value.json.form}) to /services/oauth2/token on the My Domain: client credentials
#   (grant_type=client_credentials&client_id=...&client_secret=..., needs a Run As user), or the JWT bearer / password
#   / refresh_token grants. Every other event sends Authorization: Bearer {value.json.access_token}. Keep the
#   consumer key / secret in Hub secrets (SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET) and the token on an
#   internal topic. Tokens expire per the org session timeout (401 INVALID_SESSION_ID): call GetToken again.
#   Live (2026-10-06): no or fake token -> 401 [{"errorCode":"INVALID_SESSION_ID"}]; fake client -> 400 invalid_client_id.
# Objects: ServiceMax Core (managed package SVMXC__) work orders, installed products and technicians. Asset 360
#   (SVMXA360__) orgs use the standard WorkOrder and Asset objects plus the technical attributes Apex REST service;
#   the A360* events need Asset 360, the others need ServiceMax Core. Picklist values (SVMXC__Order_Status__c,
#   SVMXC__Priority__c, SVMXC__Order_Type__c) are org-configurable: check them with DescribeWorkOrder.
# Payload fields (control fields stay out of bodies; request bodies come from {value.json.body}):
#   access_token, id, query, q, technician_id, installed_product_id, serial, asset_id, external_id_field,
#   external_id, event_name, form, body
#   q / query are URL-encoded (spaces as +). technician_id, installed_product_id, serial and asset_id are inserted
#   inside SOQL quotes: pass only Salesforce Ids or serials you trust (no quotes), never raw user input.
#     CreateWorkOrder         body = {"SVMXC__Component__c": "<installed product Id>", "SVMXC__Problem_Description__c": "..."}
#     UpdateWorkOrder         id = <work order Id>, body = {"SVMXC__Order_Status__c": "Closed"}
#     UpdateInstalledProduct  id = <installed product Id>, body = {"Run_Hours__c": 1287.5}   (custom telemetry fields)
#     A360SaveTechnicalAttributes body = {"source": "External System", "objectName": "Asset", "attributeList": [...]}
# Errors: JSON arrays [{"message","errorCode","fields"}]: 400 MALFORMED_QUERY / INVALID_FIELD /
#   REQUIRED_FIELD_MISSING / FIELD_CUSTOM_VALIDATION_EXCEPTION, 401 INVALID_SESSION_ID, 403 REQUEST_LIMIT_EXCEEDED or
#   no object access, 404 NOT_FOUND. Writes fire the org's ServiceMax triggers, flows, SLAs and dispatch rules.
#

DEFINE ROUTE ServiceMaxApi WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://YOUR_DOMAIN.my.salesforce.com"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '30'
        WITH DEFAULT_CONTENT_TYPE "application/json"
        # Smoke: GetToken -> GetLimits -> DescribeWorkOrder -> FindInstalledProductBySerial -> CreateWorkOrder -> ListOpenWorkOrdersByTechnician -> UpdateWorkOrder -> UpdateInstalledProduct. Never commit secrets or tokens.
    # Get an access token from your My Domain (form body: client_credentials, or password / JWT bearer / refresh_token grants). Reply access_token + instance_url; send access_token on every other event
    ADD EVENT GetToken
        WITH SOURCE_TOPIC "cmms/servicemax/token/create"
        WITH DESTINATION_TOPIC "cmms/servicemax/token/create/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/oauth2/token"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/x-www-form-urlencoded"
        WITH BODY "{value.json.form}"
    # Revoke an access or refresh token (form body token=...). Not a data delete
    ADD EVENT RevokeToken
        WITH SOURCE_TOPIC "cmms/servicemax/token/revoke"
        WITH DESTINATION_TOPIC "cmms/servicemax/token/revoke/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/oauth2/revoke"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/x-www-form-urlencoded"
        WITH BODY "{value.json.form}"
    # API versions the org serves (no auth needed). The route pins v65.0
    ADD EVENT ListApiVersions
        WITH SOURCE_TOPIC "cmms/servicemax/api-version/list"
        WITH DESTINATION_TOPIC "cmms/servicemax/api-version/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/"
        WITH HEADERS
            ADD "Accept" "application/json"
    # Org limits (DailyApiRequests Max / Remaining and more). Check before polling
    ADD EVENT GetLimits
        WITH SOURCE_TOPIC "cmms/servicemax/limit/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/limit/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/limits"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Any SOQL query, URL-encoded (spaces as +). Up to 2000 rows per page; nextRecordsUrl when done is false
    ADD EVENT Query
        WITH SOURCE_TOPIC "cmms/servicemax/soql/query"
        WITH DESTINATION_TOPIC "cmms/servicemax/soql/query/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q={value.json.q}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Next page of a query (the part of nextRecordsUrl after /query/)
    ADD EVENT QueryMore
        WITH SOURCE_TOPIC "cmms/servicemax/soql/next"
        WITH DESTINATION_TOPIC "cmms/servicemax/soql/next/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query/{value.json.id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Field list and the org's picklist values for SVMXC__Order_Status__c, SVMXC__Priority__c and SVMXC__Order_Type__c (they are configurable)
    ADD EVENT DescribeWorkOrder
        WITH SOURCE_TOPIC "cmms/servicemax/work-order-describe/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order-describe/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Order__c/describe"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # One ServiceMax work order by record Id (query fields=... or "" for all)
    ADD EVENT GetWorkOrder
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Order__c/{value.json.id}?{value.json.query}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Open work orders assigned to one technician (SVMXC__Group_Member__c, not closed), next scheduled first, 200 max
    ADD EVENT ListOpenWorkOrdersByTechnician
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/list-open-by-technician"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/list-open-by-technician/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q=SELECT+Id,Name,SVMXC__Order_Status__c,SVMXC__Priority__c,SVMXC__Order_Type__c,SVMXC__Component__c,SVMXC__Company__c,SVMXC__Scheduled_Date_Time__c,SVMXC__Problem_Description__c+FROM+SVMXC__Service_Order__c+WHERE+SVMXC__Group_Member__c='{value.json.technician_id}'+AND+SVMXC__Closed_On__c=null+ORDER+BY+SVMXC__Scheduled_Date_Time__c+LIMIT+200"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Open work orders for one installed product (check before opening a duplicate from a repeating alarm)
    ADD EVENT ListOpenWorkOrdersByInstalledProduct
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/list-open-by-installed-product"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/list-open-by-installed-product/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q=SELECT+Id,Name,SVMXC__Order_Status__c,SVMXC__Priority__c,SVMXC__Order_Type__c,SVMXC__Component__c,SVMXC__Company__c,SVMXC__Scheduled_Date_Time__c,SVMXC__Problem_Description__c+FROM+SVMXC__Service_Order__c+WHERE+SVMXC__Component__c='{value.json.installed_product_id}'+AND+SVMXC__Closed_On__c=null+LIMIT+200"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # WRITE: Create a ServiceMax work order from an alarm; reply 201 {id, success, errors}
    ADD EVENT CreateWorkOrder
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/create"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/create/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Order__c"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Change work order fields (status, priority, technician SVMXC__Group_Member__c, ...); reply 204 empty
    ADD EVENT UpdateWorkOrder
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/update"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/update/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Order__c/{value.json.id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Create or update a work order keyed by your own External ID field (one work order per alarm id). 201 created / 200 updated
    ADD EVENT UpsertWorkOrderByExternalId
        WITH SOURCE_TOPIC "cmms/servicemax/work-order/upsert"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order/upsert/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Order__c/{value.json.external_id_field}/{value.json.external_id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # Installed product fields, including your custom telemetry fields and SVMXC__Status__c values
    ADD EVENT DescribeInstalledProduct
        WITH SOURCE_TOPIC "cmms/servicemax/installed-product-describe/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/installed-product-describe/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Installed_Product__c/describe"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # One installed product by record Id
    ADD EVENT GetInstalledProduct
        WITH SOURCE_TOPIC "cmms/servicemax/installed-product/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/installed-product/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Installed_Product__c/{value.json.id}?{value.json.query}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # Map a machine serial number from MQTT to its installed product Id
    ADD EVENT FindInstalledProductBySerial
        WITH SOURCE_TOPIC "cmms/servicemax/installed-product/find-by-serial"
        WITH DESTINATION_TOPIC "cmms/servicemax/installed-product/find-by-serial/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q=SELECT+Id,Name,SVMXC__Serial_Lot_Number__c,SVMXC__Status__c,SVMXC__Product__c,SVMXC__Company__c,SVMXC__Site__c,SVMXC__Parent__c+FROM+SVMXC__Installed_Product__c+WHERE+SVMXC__Serial_Lot_Number__c='{value.json.serial}'+LIMIT+5"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # WRITE: Telemetry on the installed product: your custom fields (e.g. Run_Hours__c) plus SVMXC__Status__c / SVMXC__Latitude__c / SVMXC__Longitude__c; reply 204
    ADD EVENT UpdateInstalledProduct
        WITH SOURCE_TOPIC "cmms/servicemax/installed-product/update"
        WITH DESTINATION_TOPIC "cmms/servicemax/installed-product/update/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Installed_Product__c/{value.json.id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Update up to 200 records in one call (sObject Collections), e.g. run hours for a whole line; reply per-record success / errors
    ADD EVENT UpdateRecordsBatch
        WITH SOURCE_TOPIC "cmms/servicemax/record-batch/update"
        WITH DESTINATION_TOPIC "cmms/servicemax/record-batch/update/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/services/data/v65.0/composite/sobjects"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # Active technicians / equipment (SVMXC__Service_Group_Members__c) with service team and Salesforce user
    ADD EVENT ListTechnicians
        WITH SOURCE_TOPIC "cmms/servicemax/technician/list"
        WITH DESTINATION_TOPIC "cmms/servicemax/technician/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q=SELECT+Id,Name,SVMXC__Service_Group__c,SVMXC__Salesforce_User__c,SVMXC__Email__c,SVMXC__Active__c+FROM+SVMXC__Service_Group_Members__c+WHERE+SVMXC__Active__c=true+LIMIT+200"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # One technician / equipment record by Id
    ADD EVENT GetTechnician
        WITH SOURCE_TOPIC "cmms/servicemax/technician/get"
        WITH DESTINATION_TOPIC "cmms/servicemax/technician/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/sobjects/SVMXC__Service_Group_Members__c/{value.json.id}?{value.json.query}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # WRITE: Open a Salesforce Case (the usual ServiceMax entry point before a work order is dispatched)
    ADD EVENT CreateCase
        WITH SOURCE_TOPIC "cmms/servicemax/case/create"
        WITH DESTINATION_TOPIC "cmms/servicemax/case/create/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/data/v65.0/sobjects/Case"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Publish a platform event you defined (e.g. Coreflux_Telemetry__e) for Flows / Apex triggers to act on
    ADD EVENT PublishPlatformEvent
        WITH SOURCE_TOPIC "cmms/servicemax/platform-event/publish"
        WITH DESTINATION_TOPIC "cmms/servicemax/platform-event/publish/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/data/v65.0/sobjects/{value.json.event_name}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Asset 360 only: write telemetry as technical attributes (up to 200 entries, with time of occurrence) on Assets
    ADD EVENT A360SaveTechnicalAttributes
        WITH SOURCE_TOPIC "cmms/servicemax/a360-technical-attribute/save"
        WITH DESTINATION_TOPIC "cmms/servicemax/a360-technical-attribute/save/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/apexrest/SVMXA360/technicalAttribute/v1/saveTechnicalAttributes/"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # WRITE: Asset 360 only: create a standard WorkOrder on an Asset
    ADD EVENT A360CreateWorkOrder
        WITH SOURCE_TOPIC "cmms/servicemax/a360-work-order/create"
        WITH DESTINATION_TOPIC "cmms/servicemax/a360-work-order/create/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/services/data/v65.0/sobjects/WorkOrder"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    # Asset 360 only: open standard WorkOrders for one Asset
    ADD EVENT A360ListOpenWorkOrdersByAsset
        WITH SOURCE_TOPIC "cmms/servicemax/a360-work-order/list-open-by-asset"
        WITH DESTINATION_TOPIC "cmms/servicemax/a360-work-order/list-open-by-asset/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/services/data/v65.0/query?q=SELECT+Id,WorkOrderNumber,Status,Priority,Subject,AssetId,OwnerId+FROM+WorkOrder+WHERE+AssetId='{value.json.asset_id}'+AND+IsClosed=false+LIMIT+200"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    # WRITE: Asset 360 only: update a standard Asset (status or your custom telemetry fields)
    ADD EVENT A360UpdateAsset
        WITH SOURCE_TOPIC "cmms/servicemax/a360-asset/update"
        WITH DESTINATION_TOPIC "cmms/servicemax/a360-asset/update/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/services/data/v65.0/sobjects/Asset/{value.json.id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
```

Notification receiver (optional):

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
# ServiceMax notification receiver — REST_API route (server mode, beta, companion of ServicemaxRestApi.lot)
#   Receives JSON callouts that your Salesforce org sends when ServiceMax records change (a record-triggered Flow
#   with an HTTP Callout action, or an Apex trigger / queueable callout) and publishes the body on MQTT, so work
#   order and installed product changes reach the broker without polling. Opt-in: deploying it opens an HTTP port.
# Docs: https://docs.coreflux.org/latest/lot-language/routes/rest-api-apps/servicemax
#
# Salesforce needs a public HTTPS URL registered as a Named Credential / External Credential (or Remote Site).
#   Put a reverse proxy or tunnel with a public TLS certificate in front of SERVER_PORT and check a shared
#   secret header there: this route does not authenticate callers. Salesforce Outbound Messages are SOAP and need
#   a SOAP <Ack>true</Ack> reply, which this route does not send, so use Flow HTTP Callouts / Apex (JSON) instead.
#   The body is whatever your Flow or Apex sends; treat it as JSON you defined.

DEFINE ROUTE ServiceMaxNotificationReceiver WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH ENABLE_SERVER "true"
        WITH SERVER_PORT '9011'
        WITH DEFAULT_CONTENT_TYPE "application/json"
    # Flow HTTP Callout / Apex callout with JSON when a work order is created or changes (POST https://<public host>/servicemax/notify/work-orders)
    ADD EVENT NotifyWorkOrders
        WITH ENDPOINT "/servicemax/notify/work-orders"
        WITH METHOD "POST"
        WITH DESTINATION_TOPIC "cmms/servicemax/work-order-notification/received"
    # Same for installed product / Asset changes
    ADD EVENT NotifyInstalledProducts
        WITH ENDPOINT "/servicemax/notify/installed-products"
        WITH METHOD "POST"
        WITH DESTINATION_TOPIC "cmms/servicemax/installed-product-notification/received"
    # Any other JSON callout (cases, Asset 360 objects). Filter on the body in a LoT action
    ADD EVENT NotifyEvents
        WITH ENDPOINT "/servicemax/notify/events"
        WITH METHOD "POST"
        WITH DESTINATION_TOPIC "cmms/servicemax/notification/received"
```

## Example actions

These actions refresh the token every hour, open a work order when a compressor alarm is raised, write run hours to the installed product, and poll one technician's open work orders.

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
# Example actions for the ServiceMaxApi route (docs page). Not part of the route template.
# Secrets: SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET (External Client App / Connected App with the client
#   credentials flow enabled and a Run As integration user that has the ServiceMax permission sets). The access
#   token is kept on the internal topic internal/servicemax/access_token. IDs (installed product, technician, account)
#   come from installed-product/find-by-serial and technician/list; replace the <...> placeholders below.

DEFINE ACTION ServiceMaxGetToken
ON EVERY 1 HOUR DO
    SET "cid" WITH GET SECRET "SALESFORCE_CLIENT_ID"
    SET "cs" WITH GET SECRET "SALESFORCE_CLIENT_SECRET"
    SET "form" WITH "grant_type=client_credentials&client_id=" + {cid} + "&client_secret=" + {cs}
    PUBLISH TOPIC "cmms/servicemax/token/create" WITH { "form": {form} }

DEFINE ACTION ServiceMaxKeepToken
ON TOPIC "cmms/servicemax/token/create/response" DO
    SET "tok" WITH (GET JSON "access_token" IN PAYLOAD AS STRING)
    KEEP TOPIC "internal/servicemax/access_token" WITH {tok}

DEFINE ACTION ServiceMaxWorkOrderFromAlarm
ON TOPIC "porto/line1/compressor/alarm/raised" DO
    SET "tok" WITH GET TOPIC "internal/servicemax/access_token" AS STRING
    SET "alarm" WITH (GET JSON "alarm" IN PAYLOAD AS STRING)
    SET "value" WITH (GET JSON "value" IN PAYLOAD AS STRING)
    SET "desc" WITH "Coreflux alarm " + {alarm} + " (value " + {value} + ")"
    PUBLISH TOPIC "cmms/servicemax/work-order/create" WITH { "access_token": {tok}, "body": { "SVMXC__Company__c": "<Account Id>", "SVMXC__Component__c": "<installed product Id>", "SVMXC__Order_Type__c": "Field Service", "SVMXC__Priority__c": "High", "SVMXC__Problem_Description__c": {desc} } }

DEFINE ACTION ServiceMaxRunHoursTelemetry
ON TOPIC "porto/line1/press01/run-hours" DO
    SET "tok" WITH GET TOPIC "internal/servicemax/access_token" AS STRING
    SET "hours" WITH PAYLOAD AS DOUBLE
    PUBLISH TOPIC "cmms/servicemax/installed-product/update" WITH { "access_token": {tok}, "id": "<installed product Id>", "body": { "Run_Hours__c": {hours} } }

DEFINE ACTION ServiceMaxPollTechnicianWorkOrders
ON EVERY 15 MINUTES DO
    SET "tok" WITH GET TOPIC "internal/servicemax/access_token" AS STRING
    PUBLISH TOPIC "cmms/servicemax/work-order/list-open-by-technician" WITH { "access_token": {tok}, "technician_id": "<technician Id>" }
```

## Notes

* The connector pins API `v65.0`. Change the version in the endpoints to move up; `ListApiVersions` shows what your org serves.
* "Open" in the fixed queries means `SVMXC__Closed_On__c` is empty. If your org cancels work orders without a close date, use `Query` with your own status filter.
* Every call counts against the org's rolling 24-hour API allocation (`GetLimits` shows `DailyApiRequests`). Over it, Salesforce answers `403 REQUEST_LIMIT_EXCEEDED`. Poll every few minutes, not per message.
* Queries return up to 2000 rows per page; when `done` is false, pass the end of `nextRecordsUrl` to `QueryMore`.
* Writes fire the org's ServiceMax triggers, flows, SLA clocks and dispatch rules.
* `PublishPlatformEvent` needs a platform event you define (for example `Coreflux_Telemetry__e`) that Flows or Apex triggers subscribe to.

## Not covered

Deletes, work detail lines and parts, ServiceMax SFM and dispatch services, Outbound Messages, Pub/Sub API and streaming subscriptions, Bulk API, files, and Asset 360 maintenance plans, warranties and service contracts. All are reachable through the Salesforce APIs and can be added to a fork.

## Status

Technical preview, **blocked**. The template passes 363 of 363 smoke checks: static checks of both connectors against the PTC data dictionaries and Salesforce REST docs, every event against a local mock of the Salesforce REST API, negative controls, and calls to real Salesforce hosts without valid credentials (the token endpoint rejects fake clients, and every packaged call with a fake token stops at `401 INVALID_SESSION_ID`). Salesforce checks the token before it connectors a request, so those calls cannot prove that the ServiceMax objects exist in an org. No call has run yet against a real ServiceMax org.

To finish testing, use a sandbox with ServiceMax Core and an External Client App with the client credentials flow: set `SALESFORCE_INSTANCE_URL`, `SALESFORCE_CLIENT_ID`, `SALESFORCE_CLIENT_SECRET` and `SERVICEMAX_TEST_INSTALLED_PRODUCT` (and optionally `SERVICEMAX_TEST_IP_FIELD`), then run `SERVICEMAX_ALLOW_WRITE=1 python3 smoke_servicemax.py`. It reads limits, both describes and the technicians, creates a work order called "Coreflux smoke test (safe to delete)", reads it back, lists open work orders on the installed product and writes one installed product field. Then deploy the connector on a broker and do an MQTT round trip.

## Related

* [REST API connectors](/latest/lot-language/routes/rest-api-routes)
* [REST API app templates](/latest/lot-language/routes/rest-api-apps/index)
* [Salesforce](/latest/lot-language/routes/rest-api-apps/salesforce)
* [ServiceMax Core: Work Order object](https://support.ptc.com/help/servicemaxcore/en/articles/core/work-order1.html)
* [Asset 360: technical attributes REST API](https://support.ptc.com/help/servicemax_asset360/en/articles/asset-360/creating-technical-attributes-through-rest-apis.html)
* [Salesforce REST API Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.