> ## 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.

# Particle

> Client-mode REST API connector for the Particle Device Cloud: read cloud variables, call cloud functions, publish events, read device vitals, SIMs and fleet locations, manage webhooks and Ledger instances from MQTT, plus an opt-in webhook receiver

Technical Preview

## Overview

Client-mode connector for the [Particle Device Cloud API](https://docs.particle.io/reference/cloud-apis/api/). Particle devices (Photon 2, Argon, Boron, B-SoM, M-SoM, Tracker) expose **cloud variables** that you read and **cloud functions** that you call, publish and subscribe to **events**, and keep shared JSON state in **Ledger**. Fleets are grouped in **products**; cellular devices come with Particle **SIMs**.

* **ParticleRestApi** (`https://api.particle.io`) lets the Coreflux broker read a machine value from a device, call a function to change a setpoint, broadcast an event to devices, check device vitals and SIM data usage, follow trackers on a map, and push setpoints to devices through a Cloud to Device ledger.
* **ParticleWebhookReceiver** (`cloud-iot_particle_webhook.lot`, opt-in, server mode) receives Particle webhooks so device events reach MQTT without polling.

| Event | Request | Notes |
| - | - | - |
| GetAccessTokenInfo | `GET /v1/access_tokens/current` | Expiry, client and scopes of the token, a good token check |
| ListDevices | `GET /v1/devices` | Devices the token can access |
| GetDevice | `GET /v1/devices/{device_id}` | `online`, `last_heard`, the variables and functions the firmware exposes |
| GetVariable | `GET /v1/devices/{device_id}/{variable_name}?{query}` | **Catalog.** Read a `Particle.variable`; `format=raw` returns only the value |
| CallFunction | `POST /v1/devices/{device_id}/{function_name}` | **Catalog.** Call a `Particle.function` with `{"arg": "..."}` (max 63 characters) |
| PingDevice | `PUT /v1/devices/{device_id}/ping` | Is the device online? (PUT without a body) |
| UpdateDevice | `PUT /v1/devices/{device_id}` | Rename, notes, `signal` (rainbow LED) or `firmware_updates_forced` |
| GetDeviceVitals | `GET /v1/diagnostics/{device_id}/last` | Last vitals: signal, battery, memory, cloud connection |
| GetDeviceVitalsHistory | `GET /v1/diagnostics/{device_id}?{query}` | `start_date`, `end_date` (ISO 8601) |
| RefreshDeviceVitals | `POST /v1/diagnostics/{device_id}/update` | Ask the device to send fresh vitals (202) |
| PublishEvent | `POST /v1/devices/events` | **Catalog.** Cloud event to your devices and integrations: `name`, `data`, `ttl` |
| ListProducts | `GET /v1/user/products` | Products you are a team member of |
| GetProduct | `GET /v1/products/{product_id}` | Product by id or slug |
| ListProductDevices | `GET /v1/products/{product_id}/devices?{query}` | `deviceId`, `groups`, `deviceName`, `serialNumber`, `sortAttr`, `sortDir`, `quarantined`, `page`, `perPage` |
| GetProductDevice | `GET /v1/products/{product_id}/devices/{device_id}` | Groups, firmware lock, development flag |
| GetProductVariable | `GET /v1/products/{product_id}/devices/{device_id}/{variable_name}?{query}` | Product endpoint form (required for product API users) |
| CallProductFunction | `POST /v1/products/{product_id}/devices/{device_id}/{function_name}` | Product endpoint form |
| PublishProductEvent | `POST /v1/products/{product_id}/events` | Product event stream; `as_device_id` publishes as a product device |
| ListProductLocations | `GET /v1/products/{product_id}/locations?{query}` | Asset tracking: `date_range`, `rect_bl`, `rect_tr`, `device_id`, `device_name`, `groups`, `page`, `per_page` |
| GetProductDeviceLocation | `GET /v1/products/{product_id}/locations/{device_id}?{query}` | Location history of one device |
| GetProductOnlineMetrics | `GET /v1/products/{product_id}/metrics/online?{query}` | Online device counts per bucket (fleet health) |
| ListProductFirmware | `GET /v1/products/{product_id}/firmware` | Product firmware versions |
| ListSims | `GET /v1/sims?{query}` | `iccid`, `deviceId`, `deviceName`, `page`, `perPage` |
| GetSim | `GET /v1/sims/{iccid}` | SIM status and limits |
| GetSimDataUsage | `GET /v1/sims/{iccid}/data_usage` | Daily MB used this billing period |
| GetSimStatus | `GET /v1/sims/{iccid}/status` | Cellular network status (may answer 202 first) |
| ListIntegrations | `GET /v1/integrations` | Webhooks and other integrations |
| GetIntegration | `GET /v1/integrations/{integration_id}` | One integration with recent logs |
| CreateWebhook | `POST /v1/integrations` | Webhook to any URL, for example the broker receiver |
| TestIntegration | `POST /v1/integrations/{integration_id}/test` | Sends a test event to the target |
| ListProductIntegrations | `GET /v1/products/{product_id}/integrations` | Integrations of a product |
| CreateProductWebhook | `POST /v1/products/{product_id}/integrations` | Webhook for every device in the product |
| ListLedgers | `GET /v1/ledgers?{query}` | Ledger definitions (`page`, `perPage`, `archived`) |
| GetLedger | `GET /v1/ledgers/{ledger_name}` | One ledger definition |
| ListLedgerInstances | `GET /v1/ledgers/{ledger_name}/instances?{query}` | Instances of a ledger |
| GetLedgerInstance | `GET /v1/ledgers/{ledger_name}/instances/{scope_value}` | Read an instance (JSON) |
| SetLedgerInstance | `PUT /v1/ledgers/{ledger_name}/instances/{scope_value}?{query}` | Write an instance; `set_mode=Replace` or `Merge` |
| ListLedgerInstanceVersions | `GET /v1/ledgers/{ledger_name}/instances/{scope_value}/versions?{query}` | `replaced_before`, `replaced_after` |
| GetProductLedgerInstance | `GET /v1/products/{product_id}/ledgers/{ledger_name}/instances/{scope_value}` | Product endpoint form |
| SetProductLedgerInstance | `PUT /v1/products/{product_id}/ledgers/{ledger_name}/instances/{scope_value}?{query}` | Product endpoint form |
| GetOrgLedgerInstance | `GET /v1/orgs/{org_id}/ledgers/{ledger_name}/instances/{scope_value}` | Organization ledger |
| SetOrgLedgerInstance | `PUT /v1/orgs/{org_id}/ledgers/{ledger_name}/instances/{scope_value}?{query}` | Organization ledger |

Topics are `cloud-iot/particle/<object>/<verb>` and every reply goes to `<topic>/response`. There are no DELETE events. `product_id` takes the product id or slug, `org_id` the organization id or slug.

See the base guide: [REST API Connectors](/latest/lot-language/routes/rest-api-routes).

> [!NOTE]
>
> Factory live smoke is **blocked** until a Particle access token and a test device are supplied. The connectors were checked against Particle's published API description and the live API without a token only.

## Authentication

Every event sends `Authorization: Bearer {access_token}`.

* A **user token** is the quickest start: `particle token create` (valid 90 days unless you pass `--expires-in`). It can do anything your account can.
* For unattended plant use, create a **product or organization API user** with only the scopes its events need, for example `devices:list`, `devices.variable:get`, `devices.function:call`, `events:send`, `integrations:create`, `ledger.instances:get` and `ledger.instances:set`. An API user has one token at a time and must use the product (`/v1/products/...`) or organization (`/v1/orgs/...`) events.

Keep the token in a broker secret (suggested `PARTICLE_ACCESS_TOKEN`, plus `PARTICLE_DEVICE_ID` / `PARTICLE_PRODUCT_ID`) and map it in with `GET SECRET`. Particle still accepts a token in the query string or a form body, but both are deprecated; this template uses the header only.

A missing token returns `400 {"error": "invalid_request", "error_description": "The access token was not found"}`, a wrong or expired one `401 {"error": "invalid_token", ...}`. Particle checks the token before it looks at the path, so a 400 / 401 does not tell you whether the path exists.

## Basic Syntax

```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE ParticleRestApi WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://api.particle.io"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '30'
        WITH DEFAULT_CONTENT_TYPE "application/json"
    ADD EVENT GetVariable
        WITH SOURCE_TOPIC "cloud-iot/particle/device-variable/get"
        WITH DESTINATION_TOPIC "cloud-iot/particle/device-variable/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/v1/devices/{value.json.device_id}/{value.json.variable_name}?{value.json.query}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT CallFunction
        WITH SOURCE_TOPIC "cloud-iot/particle/device-function/call"
        WITH DESTINATION_TOPIC "cloud-iot/particle/device-function/call/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/v1/devices/{value.json.device_id}/{value.json.function_name}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
    ADD EVENT PublishEvent
        WITH SOURCE_TOPIC "cloud-iot/particle/event/publish"
        WITH DESTINATION_TOPIC "cloud-iot/particle/event/publish/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/v1/devices/events"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "Content-Type" "application/json"
        WITH BODY "{value.json.body}"
```

The full template, with all 42 events, is `ParticleRestApi.lot` in the REST API app.

## MQTT examples

The connector sends only `{value.json.body}` as the request body. Keep `access_token`, `device_id`, `product_id`, `org_id`, `variable_name`, `function_name`, `ledger_name`, `scope_value`, `iccid`, `integration_id` and `query` at the top level of the MQTT payload. Device ids are the 24-character hex ids shown in the Particle console. Send `"query": ""` when you do not need query parameters.

**Read a cloud variable**: publish to `cloud-iot/particle/device-variable/get`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Particle access token>",
  "device_id": "0123456789abcdef01234567",
  "variable_name": "temperature",
  "query": ""
}
```

Particle answers `{"name": "temperature", "result": 21.4, "coreInfo": {...}}`; with `"query": "format=raw"` only `21.4`. The device must be online: an offline device answers `404`, a variable the firmware does not expose `400`.

**Call a cloud function**: publish to `cloud-iot/particle/device-function/call`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Particle access token>",
  "device_id": "0123456789abcdef01234567",
  "function_name": "setSpeed",
  "body": { "arg": "1200" }
}
```

The reply carries the integer the firmware function returns: `{"id": "...", "connected": true, "return_value": 1200}`. The argument is a string of at most 63 characters.

**Publish an event to devices**: publish to `cloud-iot/particle/event/publish`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Particle access token>",
  "body": { "name": "coreflux/line-porto-1/shift", "data": "{\"shift\":\"B\",\"target\":1200}", "ttl": 60 }
}
```

Devices receive it with `Particle.subscribe("coreflux/line-porto-1/", handler)`. For a product fleet use `cloud-iot/particle/product-event/publish` with `"product_id"`.

**Push setpoints through Ledger**: publish to `cloud-iot/particle/ledger-instance/set`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Particle access token>",
  "ledger_name": "line-setpoints",
  "scope_value": "0123456789abcdef01234567",
  "query": "set_mode=Merge",
  "body": { "instance": { "data": { "speed_rpm": 1200, "recipe": "R-42" } } }
}
```

A Cloud to Device ledger syncs to the device as soon as it is online, so the setpoint arrives even if the device is asleep now. `set_mode=Replace` (the default) overwrites the instance, `Merge` updates only the keys you send. Use the `product-ledger-instance` or `org-ledger-instance` events for product or organization ledgers.

**Fleet checks**: `cloud-iot/particle/product-device/list` with `"query": "groups=porto-1&perPage=100"`, `cloud-iot/particle/device-vitals/get` for signal and battery, `cloud-iot/particle/sim-data-usage/get` with `"iccid"`, and `cloud-iot/particle/product-location/list` with `"query": "per_page=100"` for tracker positions.

## Receive Particle events (webhook receiver)

Polling variables costs a data operation per call. To get device events pushed to MQTT, deploy the companion file `cloud-iot_particle_webhook.lot`:

```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE ParticleWebhookReceiver WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH ENABLE_SERVER "true"
        WITH SERVER_PORT '9001'
        WITH DEFAULT_CONTENT_TYPE "application/json"
    ADD EVENT WebhookTelemetry
        WITH ENDPOINT "/particle/webhook/telemetry"
        WITH METHOD "POST"
        WITH DESTINATION_TOPIC "cloud-iot/particle/webhook-telemetry/received"
    ADD EVENT WebhookEvents
        WITH ENDPOINT "/particle/webhook/events"
        WITH METHOD "POST"
        WITH DESTINATION_TOPIC "cloud-iot/particle/webhook-event/received"
```

Then create a Particle webhook that points at it, for example by publishing to `cloud-iot/particle/webhook/create`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Particle access token>",
  "body": {
    "integration_type": "Webhook",
    "event": "telemetry",
    "url": "https://broker.example.com/particle/webhook/telemetry",
    "requestType": "POST",
    "noDefaults": true,
    "json": {
      "event": "{{{PARTICLE_EVENT_NAME}}}",
      "data": "{{{PARTICLE_EVENT_VALUE}}}",
      "coreid": "{{{PARTICLE_DEVICE_ID}}}",
      "published_at": "{{{PARTICLE_PUBLISHED_AT}}}"
    },
    "headers": { "Authorization": "Bearer <receiver secret>" }
  }
}
```

Every `Particle.publish("telemetry", ...)` from your devices now arrives on `cloud-iot/particle/webhook-telemetry/received`. `cloud-iot/particle/integration/test` sends a test delivery.

1. Deploying the receiver opens HTTP port `9001` on the broker.
2. Particle delivers from its cloud to a public URL. Put a reverse proxy or tunnel with a public TLS certificate in front of port `9001` and forward `/particle/webhook/...`.
3. Accept only Particle. The connector does not check secrets itself: let the proxy check the `Authorization` header you set on the webhook (or Basic `auth`, or a Particle cloud secret inserted with `{{{SECRET_NAME}}}`).
4. Use the **JSON** request format. The default Web Form format arrives URL-encoded.
5. Answer fast: Particle waits 20 seconds and throttles a webhook whose target fails or times out too often.
6. `data` is the string the device published (often JSON text): parse it in a LoT action. Retries can repeat a delivery, so deduplicate on `coreid` + `published_at`.

## Helper actions

Call a function from a plant setpoint topic, poll a variable every minute, and connector webhook telemetry per device, with the token and device id kept in broker secrets:

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ACTION ParticleSetLineSpeed
ON TOPIC "porto/line-1/speed/set" DO
    SET "tok" WITH GET SECRET "PARTICLE_ACCESS_TOKEN"
    SET "dev" WITH GET SECRET "PARTICLE_DEVICE_ID"
    SET "rpm" WITH GET JSON "rpm" IN PAYLOAD AS STRING
    PUBLISH TOPIC "cloud-iot/particle/device-function/call" WITH { "access_token": {tok}, "device_id": {dev}, "function_name": "setSpeed", "body": { "arg": {rpm} } }

DEFINE ACTION ParticlePollTemperature
ON EVERY 1 MINUTE DO
    SET "tok" WITH GET SECRET "PARTICLE_ACCESS_TOKEN"
    SET "dev" WITH GET SECRET "PARTICLE_DEVICE_ID"
    PUBLISH TOPIC "cloud-iot/particle/device-variable/get" WITH { "access_token": {tok}, "device_id": {dev}, "variable_name": "temperature", "query": "" }

DEFINE ACTION ParticleWebhookToPlant
ON TOPIC "cloud-iot/particle/webhook-telemetry/received" DO
    SET "dev" WITH (GET JSON "coreid" IN PAYLOAD AS STRING)
    SET "data" WITH (GET JSON "data" IN PAYLOAD AS STRING)
    PUBLISH TOPIC "plant/particle/" + {dev} + "/telemetry" WITH {data}
```

Restrict who can publish to `cloud-iot/particle/#` with a rule: its payloads carry the token, and the function, event and ledger events change how devices in the field behave.

## Notes

* **Rate limits:** 10,000 API requests every 5 minutes per public IP address; a `429` means slow down (retrying at once does not help). The serial-number lookup and the event streams have stricter limits. Polling many variables quickly hits the limit and costs data operations on cellular devices; prefer events plus the webhook receiver.
* **Errors** come back on the response topic: `400` (bad request, or the variable / function is not exposed), `401` (token invalid or expired), `403` (token not allowed for this device or product), `404` (device offline or unknown), `408` (the device did not answer in time), `429`, `5xx`.
* **Ledger:** names are lowercase letters, digits and dashes, up to 32 characters. Device-synced ledgers hold up to 16 KB of JSON, cloud-only ledgers up to 1 MB. Device to Cloud and Cloud to Device ledgers need Device OS 6.1.0 or later. The API reference lists the set body as `{"data": ...}`; the official JavaScript SDK sends `{"instance": {"data": ...}}` with `set_mode`. This template passes the body through unchanged, so either form can be sent.
* **Not packaged:** Server-Sent Event streams (`GET /v1/devices/events/...`, product and organization streams) because a REST\_API connector cannot hold a stream open (use the webhook receiver instead), every DELETE (unclaim, remove from product, delete ledger instance, delete integration), firmware flashing and compiling, claiming and importing devices, SIM activation, customers, groups, Logic functions, environment variables, cloud secrets and OAuth clients.
* Only the global host `https://api.particle.io` is documented (TLS 1.2 or later).

## Related

* [REST API Connectors](/latest/lot-language/routes/rest-api-routes)
* [REST API app templates](/latest/lot-language/routes/rest-api-apps/index)
* [Losant](/latest/lot-language/routes/rest-api-apps/losant)
* [balena](/latest/lot-language/routes/rest-api-apps/balena)


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