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

# Azure Digital Twins

> Client-mode REST API connectors for Azure Digital Twins: patch twin properties from plant telemetry with JSON Patch, upsert twins and relationships, query the twin graph, send twin telemetry into ADT event routes, and read twins, relationships, DTDL models and event routes, with an Entra ID client-credentials token connector

Technical Preview

## Overview

Two client-mode connectors for [Azure Digital Twins](https://learn.microsoft.com/en-us/azure/digital-twins/overview):

* **AzureDigitalTwins** calls your instance's data plane (`https://<instance>.api.<region>.digitaltwins.azure.net`, api-version `2023-10-31`). Use it to keep a twin graph in step with the plant: patch twin properties from UNS telemetry, create or replace twins and relationships, query the graph, and publish telemetry on a twin so ADT event routes forward it to Event Grid, Event Hubs or Service Bus.
* **AzureDigitalTwinsEntraToken** calls Microsoft Entra ID (`login.microsoftonline.com`) with the client-credentials flow and returns an access token for the `https://digitaltwins.azure.net` audience.

| Event | Request | Notes |
| - | - | - |
| UpdateTwin | `PATCH /digitaltwins/{twin_id}` | JSON Patch array, `Content-Type: application/json-patch+json`; returns `204` |
| GetTwin | `GET /digitaltwins/{twin_id}` | Twin with `$dtId`, `$etag`, `$metadata` and properties |
| UpsertTwin | `PUT /digitaltwins/{twin_id}` | Create or replace; body needs `$metadata.$model` |
| QueryTwins | `POST /query` | ADT query language; `{"query": "..."}` or `{"continuationToken": "..."}` |
| SendTelemetry | `POST /digitaltwins/{twin_id}/telemetry` | `Message-Id` header required (unique per message); returns `204` |
| ListRelationships | `GET /digitaltwins/{twin_id}/relationships` | Outgoing relationships |
| UpsertRelationship | `PUT /digitaltwins/{twin_id}/relationships/{relationship_id}` | Body: `$targetId`, `$relationshipName` |
| ListModels | `GET /models` | DTDL models in the instance |
| GetModel | `GET /models/{model_id}?includeModelDefinition=true` | DTDL definition of one model |
| ListEventRoutes | `GET /eventroutes` | Where twin changes and telemetry are forwarded |
| GetAccessToken | `POST /{tenant_id}/oauth2/v2.0/token` | Client credentials, scope `https://digitaltwins.azure.net/.default` |

Topics use `cloud-iot/azure-digital-twins/<object>/<verb>`; replies land on `<topic>/response`. Before deploying, replace `YOUR_INSTANCE.api.YOUR_REGION` in the `AzureDigitalTwins` connector with your instance host name, shown as **Host name** on the instance overview in the Azure portal or by `az dt show`, for example `plant-porto.api.weu.digitaltwins.azure.net`.

> [!WARNING]
>
> Never commit a client secret or access token. Store the tenant id, client id and client secret as broker secrets and map them into payloads with `GET SECRET`.

## Authentication

Every data-plane event sends a **Microsoft Entra ID access token** as `Authorization: Bearer <token>`. The token must be issued for the Azure Digital Twins audience `https://digitaltwins.azure.net`.

1. Register an app in Microsoft Entra ID (or use a managed identity if the broker runs on Azure) and create a client secret.
2. On the Azure Digital Twins instance, assign that identity **Azure Digital Twins Data Owner** for read and write, or **Azure Digital Twins Data Reader** for read only. Owning the Azure subscription is not enough; the data-plane role is required.
3. Publish `tenant_id`, `client_id` and `client_secret` to `cloud-iot/azure-digital-twins/token/get`. The reply carries `access_token` and `expires_in` (about one hour). The client secret must be URL-encoded because it goes into a form body.
4. Pass `access_token` on every data-plane payload. Refresh it before it expires; the helper actions below do this every 45 minutes.

A missing or expired token returns `401`. A valid token without the data-plane role returns `403`.

## Basic Syntax

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE AzureDigitalTwins WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://YOUR_INSTANCE.api.YOUR_REGION.digitaltwins.azure.net"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '30'
        WITH DEFAULT_CONTENT_TYPE "application/json"
    ADD EVENT UpdateTwin
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/twin/update"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/twin/update/response"
        WITH METHOD "PATCH"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json-patch+json"
            ADD "Authorization" "Bearer {value.json.access_token}"
        WITH BODY "{value.json.body}"
    ADD EVENT GetTwin
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/twin/get"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/twin/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT UpsertTwin
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/twin/upsert"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/twin/upsert/response"
        WITH METHOD "PUT"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
        WITH BODY "{value.json.body}"
    ADD EVENT QueryTwins
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/query/run"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/query/run/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/query?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
        WITH BODY "{value.json.body}"
    ADD EVENT SendTelemetry
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/telemetry/send"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/telemetry/send/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}/telemetry?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Message-Id" "{value.json.message_id}"
            ADD "Authorization" "Bearer {value.json.access_token}"
        WITH BODY "{value.json.body}"
    ADD EVENT ListRelationships
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/relationship/list"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/relationship/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}/relationships?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT UpsertRelationship
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/relationship/upsert"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/relationship/upsert/response"
        WITH METHOD "PUT"
        WITH ENDPOINT "/digitaltwins/{value.json.twin_id}/relationships/{value.json.relationship_id}?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
        WITH BODY "{value.json.body}"
    ADD EVENT ListModels
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/model/list"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/model/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/models?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT GetModel
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/model/get"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/model/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/models/{value.json.model_id}?includeModelDefinition=true&api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT ListEventRoutes
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/event-route/list"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/event-route/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/eventroutes?api-version=2023-10-31"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"

DEFINE ROUTE AzureDigitalTwinsEntraToken WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://login.microsoftonline.com"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '30'
        WITH DEFAULT_CONTENT_TYPE "application/x-www-form-urlencoded"
    ADD EVENT GetAccessToken
        WITH SOURCE_TOPIC "cloud-iot/azure-digital-twins/token/get"
        WITH DESTINATION_TOPIC "cloud-iot/azure-digital-twins/token/get/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/{value.json.tenant_id}/oauth2/v2.0/token"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/x-www-form-urlencoded"
        WITH BODY "grant_type=client_credentials&client_id={value.json.client_id}&client_secret={value.json.client_secret}&scope=https%3A%2F%2Fdigitaltwins.azure.net%2F.default"
```

## MQTT examples

Request bodies go under `body`, so control fields like `access_token`, `twin_id` and `message_id` are not sent to Azure.

**Patch a twin from telemetry**: publish to `cloud-iot/azure-digital-twins/twin/update`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Entra access token>",
  "twin_id": "press-01",
  "body": [
    {"op": "add", "path": "/Temperature", "value": 71.4},
    {"op": "add", "path": "/State", "value": "Running"}
  ]
}
```

A successful patch returns `204` with an empty body. `replace` fails with `400` if the property has never been set on the twin, so use `add` for the first write. Every property must exist in the twin's DTDL model.

**Create or replace a twin**: publish to `cloud-iot/azure-digital-twins/twin/upsert`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Entra access token>",
  "twin_id": "press-01",
  "body": {"$metadata": {"$model": "dtmi:coreflux:Machine;1"}, "Temperature": 0, "State": "Idle"}
}
```

**Link it to its line**: publish to `cloud-iot/azure-digital-twins/relationship/upsert`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Entra access token>",
  "twin_id": "press-01",
  "relationship_id": "press-01-isPartOf-line-1",
  "body": {"$targetId": "line-1", "$relationshipName": "isPartOf"}
}
```

**Query the graph**: publish to `cloud-iot/azure-digital-twins/query/run`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Entra access token>",
  "body": {"query": "SELECT T FROM DIGITALTWINS T WHERE IS_OF_MODEL(T, 'dtmi:coreflux:Machine;1') AND T.Temperature > 80"}
}
```

The reply has `value` (the matching twins) and `continuationToken`. Send `{"continuationToken": "..."}` as the body to get the next page.

**Send telemetry on a twin**: publish to `cloud-iot/azure-digital-twins/telemetry/send`:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<Entra access token>",
  "twin_id": "press-01",
  "message_id": "press-01-2026-10-05T03:15:00Z",
  "body": {"Vibration": 9.2}
}
```

Telemetry is not stored on the twin. ADT forwards it to whatever event routes you configured (check them on `cloud-iot/azure-digital-twins/event-route/list`). `message_id` must be unique per message; ADT uses it to drop duplicates.

**Read**: publish `{"access_token": "<token>", "twin_id": "press-01"}` to `cloud-iot/azure-digital-twins/twin/get` or `.../relationship/list`, `{"access_token": "<token>"}` to `.../model/list` or `.../event-route/list`, and `{"access_token": "<token>", "model_id": "dtmi%3Acoreflux%3AMachine%3B1"}` to `.../model/get`. URL-encode the model id because it contains `:` and `;`.

## Helper actions

Keep the Entra credentials in broker secrets, refresh the token on a timer and keep it in broker-internal state, so machines only publish their raw values:

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ACTION AzureDigitalTwinsRefreshToken
ON EVERY 45 MINUTES DO
    SET "tenant" WITH GET SECRET "ADT_TENANT_ID"
    SET "client" WITH GET SECRET "ADT_CLIENT_ID"
    SET "secret" WITH GET SECRET "ADT_CLIENT_SECRET"
    PUBLISH TOPIC "cloud-iot/azure-digital-twins/token/get" WITH { "tenant_id": {tenant}, "client_id": {client}, "client_secret": {secret} }

DEFINE ACTION AzureDigitalTwinsKeepToken
ON TOPIC "cloud-iot/azure-digital-twins/token/get/response" DO
    SET "token" WITH GET JSON "access_token" IN PAYLOAD AS STRING
    KEEP TOPIC "state/azure-digital-twins/token" WITH {token}

DEFINE ACTION AzureDigitalTwinsSyncTemperature
ON TOPIC "plant/porto/line1/+/temperature" DO
    SET "machine" WITH TOPIC POSITION 4
    SET "temp" WITH PAYLOAD AS DOUBLE
    SET "token" WITH GET TOPIC "state/azure-digital-twins/token" AS STRING
    PUBLISH TOPIC "cloud-iot/azure-digital-twins/twin/update" WITH { "access_token": {token}, "twin_id": {machine}, "body": [ { "op": "add", "path": "/Temperature", "value": {temp} } ] }
```

Here the fourth topic segment (`press-01` in `plant/porto/line1/press-01/temperature`) is the twin id. Name twins after your UNS asset ids so this mapping stays trivial. Store a client secret that is already URL-encoded, or encode it before publishing. Restrict who can read `cloud-iot/azure-digital-twins/#` with a rule: these payloads carry tokens and secrets.

## Notes

* Errors come back on the response topic: `401` for a missing, expired or wrong-audience token, `403` without the Data Owner or Data Reader role, `404` (`DigitalTwinNotFound`, `RelationshipNotFound`, `ModelNotFound`), `400` for a bad JSON Patch, a property that is not in the DTDL model or an invalid query, `412` for a failed `If-Match` / `If-None-Match` precondition, `429` when throttled.
* ADT throttles requests per instance and per twin (see [Azure Digital Twins service limits](https://learn.microsoft.com/en-us/azure/digital-twins/reference-service-limits)). Batch high-rate telemetry or use `ON CHANGE` instead of patching on every sample.
* `PATCH` and `PUT` here send no `If-Match` / `If-None-Match` header, so they overwrite unconditionally. Add the header to the event if you need optimistic concurrency.
* Model upload and decommission, deletes, import and delete jobs, event connector writes, component telemetry and component patches, and the ARM control plane (creating instances) are not part of this template.

## Related

* [REST API Connectors](/latest/lot-language/routes/rest-api-routes)
* [REST API app templates](/latest/lot-language/routes/rest-api-apps/index)
* [Azure IoT Hub](/latest/lot-language/routes/rest-api-apps/azure-iot-hub)
* [Azure Data Explorer and Fabric Eventhouse](/latest/lot-language/routes/rest-api-apps/azure-data-explorer-and-fabric-eventhouse)


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