Inbound Integrations
Most devices connect to DeviceChain themselves, over MQTT or HTTP. Some never do: a LoRaWAN sensor talks to a gateway, and a network server such as ChirpStack or The Things Stack decodes what the gateway heard and forwards it over HTTPS. An inbound integration is the receiving end of that forward. It gives the sender a URL and a bearer token, finds the device each message is about, and stores the readings as ordinary measurements. From there on they behave like any other measurement: they have units, drive dashboards, trigger detection rules and raise alarms.
The same mechanism takes any other system that can send an HTTP POST, with JSON or a binary body, and you say where the device identifier is. This page covers ChirpStack v4, The Things Stack v3, generic JSON and generic binary, and then what is stored, how duplicates are handled, the limits, and what v1 does not do.
How it fits together
An integration is a tenant resource with these settings:
| Setting | Meaning |
|---|---|
| Token | The integration's name in the tenant. It is the last segment of the URL, and you choose it (chirpstack-eu868). It cannot change. |
| Sender type | CHIRPSTACK_V4, TTS_V3, GENERIC_JSON or GENERIC_BINARY. It fixes how the body is read, and it cannot change. The body is never guessed from its shape. |
| Device types | The scope: only devices of these types are reached through the integration, whatever the sender claims. At least one, at most 50. |
| Device identifier | How the sender names a device: by DEV_EUI (the default), by DEVICE_ID (The Things Stack only), or, for a generic sender, by an alias scheme you name (ALIAS:custom.serial). |
| Generic settings | Generic senders only: where the identifier is, where the array of uplinks is, and where the sender's own time is. |
| Store link metrics | Off by default. Stores signal strength, signal-to-noise ratio and gateway count beside each uplink. |
| Accepting data | Switch an integration off to refuse its traffic at once without deleting it. |
| Bearer token | Generated by the server (256 random bits), shown once when you create the integration or rotate its token, and stored only as a keyed digest. |
Each message goes through the same steps:
- The platform checks the bearer token against the integration named in the URL, before it reads the body.
- It reads the body by the integration's sender type and takes out the device identifier.
- It looks up the device that carries that identifier as an alias. A device outside the integration's scope, or no device at all, means the message is not stored.
- It turns the decoded values into readings and stores them as measurements of that device.
How many integrations a tenant may hold is a tier setting, Integrations per tenant. The platform default is 5, a tier can set up to 100, and 0 means none. Creating one past the ceiling is refused.
The URL
Every integration is reached at one public address:
POST https://<host>/api/integrations/{tenant}/{integration}[/{messageType}]
Authorization: Bearer <bearer-token>
<host>is the address of your instance, the same one you open the console on. Substitute it; the instance id is not part of the URL.{tenant}is the tenant's token and{integration}is the integration's token.{messageType}is optional. Only The Things Stack uses it, because it posts each kind of message to a path of its own.- Only
POSTis accepted. Any other method is answered405. - The token goes in the
Authorizationheader and nowhere else. A token in the URL would be written to access logs.
The console shows the URL, the header and the settings the sender needs when you create the integration, on the dialog that shows the token once, and again on the integration's page under Connect the sender.
A hosted network server will not trust an instance that serves a self-signed certificate. Give the host a certificate from a public CA (ingress.tls.clusterIssuer) before you connect a hosted one. A network server you run yourself can be given your CA instead.
If you operate the instance, event-sources must also be told which proxies in front of it to believe for a client's address (infrastructure.http.trustedProxies), or every sender shares one request allowance. dcctl sets it; see Client addresses and trusted proxies.
Before you connect a sender
- Have a device type with a profile. Devices of this type are the ones the integration can reach. Declare a metric for each decoded value you want units or validation for, using the same key the decoded object produces. Keys you do not declare are still stored, without a unit.
- Create the integration. In the console, open Connect → Integrations in the sidebar and choose New integration. Pick the Sender type, the Device types it may reach and, for a network server, the Device identifier. Turn on Store link metrics if you want signal data. Choose Create integration, and copy the bearer token from the dialog. It is shown once; if you lose it, rotate it. The Integrations page needs the
device:readpermission to open anddevice:writeto change. - Add the devices with their aliases, as in the next section. You can do this before or after the sender starts: messages for a device that does not exist yet are listed so that you can create it from what was seen.
With GraphQL, createIntegration returns the integration and the token together:
mutation {
createIntegration(request: {
token: "chirpstack-eu868"
name: "ChirpStack EU868"
type: "CHIRPSTACK_V4"
enabled: true
deviceTypeTokens: ["lora-sensor"]
linkMetrics: true
}) {
integration { token type identity }
issuedToken
}
}
rotateIntegrationToken(token) replaces the token and returns the new one once. The old token stops working at once, with no overlap, so update the sender straight after. updateIntegration changes everything except the token and the type. deleteIntegration removes the integration; the aliases on devices stay.
Identify devices with aliases
A message names a device the way the sender knows it, not the way DeviceChain does. A device alias records that name on the device. An alias is a scheme and a value:
| Scheme | The value | Stored as |
|---|---|---|
lorawan.deveui | The LoRaWAN DevEUI: exactly 16 hexadecimal digits. | Upper case. 70b3d57ed0012345 and 70B3D57ED0012345 are the same alias. |
tts.device-id | The device id the device has in The Things Stack: 1 to 36 letters, digits and hyphens, not starting or ending with a hyphen. | Lower case. |
custom.<name> | Any identifier of your own, for a generic sender: 1 to 128 characters with no control characters. The name starts with a lower-case letter or digit and continues with lower-case letters, digits, hyphens and underscores, up to 63 characters in all. | As written. Case matters. |
A device holds at most 8 aliases and one value per scheme, and a scheme and value can belong to only one device in the tenant. Writing a DevEUI in lower case, or with surrounding spaces, is fine: it is normalized when it is stored and when a message arrives. Anything that is not 16 hexadecimal digits is refused.
Set an alias:
- in the console, in the Aliases field of the device form: choose Add alias, pick LoRaWAN DevEUI, Network server device id or Custom scheme, and type the value;
- over GraphQL, with
aliases: [{scheme: "lorawan.deveui", value: "70B3D57ED0012345"}]oncreateDeviceorupdateDevice. On an update, the list you send replaces the device's aliases, and an empty list removes them; - on
importDevices, in the samealiasesfield.
mutation {
createDevice(request: {
token: "boiler-room-3"
deviceTypeToken: "lora-sensor"
aliases: [{ scheme: "lorawan.deveui", value: "70b3d57ed0012345" }]
}) { token aliases { scheme value } }
}
To find a device by alias, give devices a criteria alias: {scheme: "lorawan.deveui", value: "..."}.
The integration's Device identifier decides which scheme it looks up: DEV_EUI reads lorawan.deveui, DEVICE_ID reads tts.device-id, and ALIAS:custom.serial reads custom.serial. A device that carries only a different scheme is not found.
Changing or removing a device's identifier. Moving an identifier (a DevEUI, a device id) to another device, or removing it, takes effect within about five minutes. Until then the integration endpoint may still name the old device for that identifier, but the platform checks at the event that the device still holds the identifier, so those uplinks are refused and kept in the dead-letter store for inspection (reason IntegrationAliasMismatch, counted by resolve_integration_alias_mismatch_total). They are never stored on any device and cannot be replayed. Once the change has propagated, uplinks land on the device that holds the identifier now. A device you create for an identifier that was already arriving starts receiving within a minute.
ChirpStack v4
ChirpStack sends uplinks through an HTTP integration on an application.
-
In the console, create an integration of sender type ChirpStack v4, with the device types and, if you want them, link metrics. Copy the bearer token.
-
In ChirpStack, open the application, add the HTTP integration and fill it in:
ChirpStack field Value Payload encoding JSON. A Protobuf integration is refused with 415, and the message names this setting.Event endpoint URL(s) https://<host>/api/integrations/{tenant}/{integration}. ChirpStack adds?event=upitself.Headers Name Authorization, valueBearer <bearer-token>. -
Set a codec on the ChirpStack device profile (a JavaScript payload codec). The platform stores the values the network server decodes; it does not decode the raw frame. Without a codec, ChirpStack sends the raw frame and no decoded object, and the uplink is counted as undecoded instead of stored.
-
Add the devices in DeviceChain with their DevEUI as a
lorawan.deveuialias.
Only uplinks are read. ChirpStack sends other events (join, status, acknowledgements) to the same URL with a different event value, and they are answered 204 and ignored. The uplink's deduplicationId must be a UUID, which ChirpStack always sends; anything else is refused with 400. The message time is the time ChirpStack reports, or the time the platform received it when the field is absent.
The Things Stack v3
The Things Stack sends uplinks through a webhook on an application.
-
In the console, create an integration of sender type The Things Stack v3. Under Device identifier choose DevEUI, or Device id if you would rather match the device id from The Things Stack. Copy the bearer token.
-
In The Things Stack, open the application, add a Custom webhook, set the format to JSON, and fill it in:
The Things Stack field Value Base URL https://<host>/api/integrations/{tenant}/{integration}Additional headers Name Authorization, valueBearer <bearer-token>.Enabled event types Uplink message, enabled, with the path /uplink-message. The platform also reads the paths/up,/uplinkand/uplinks. -
Set a payload formatter on the application or on the device (the LoRaWAN Device Repository offers one for many sensors). The platform stores the formatter's output; it does not decode the raw frame. Without one, the uplink is counted as undecoded.
-
Add the devices in DeviceChain with their DevEUI as a
lorawan.deveuialias, or their device id as atts.device-idalias if you chose Device id.
A message posted to any other path (a join accept, a downlink acknowledgement) is answered 204 and ignored, so you can enable other message types without changing anything in DeviceChain, but there is no reason to. The message time is the uplink's received_at, which must be present and not older than 366 days. The deduplication key is the device, the frame counter and that time together, because the counter restarts when a device rejoins.
Generic JSON
Use a generic JSON integration for any system that can post JSON: a gateway vendor's cloud, a middleware, a script. You tell the platform where to find the pieces. When you create the integration choose sender type Generic JSON, set Device identifier to the alias scheme the identifiers are in (ALIAS:custom.serial, for example), and fill in the settings. Over GraphQL they are the generic field, a JSON document in a string:
| Setting | Meaning |
|---|---|
idFrom | Required. Where the device identifier is: pointer:/device/id (a JSON pointer into each item), header:X-Device-Id (a request header) or query:device (a query parameter). A header or query identifier applies to every item of the request. |
itemsPointer | Optional. A pointer to an array in the body; each element is one uplink. Without it the body is one uplink, or a bare array of uplinks. |
timePointer | Optional. A pointer to the time the sender recorded, as an RFC 3339 string or an epoch number in seconds or milliseconds. Without it the time of receipt is used. |
framePointer, frameEncoding | Optional. Where an encoded raw frame is (HEX or BASE64). It is only sampled, to help you see what an undecoded uplink held. |
Every key in an item except the identifier and the time is a candidate reading. For example, with idFrom set to pointer:/serial, itemsPointer set to /readings and timePointer set to /ts:
curl -X POST https://dc.example.com/api/integrations/acme/vendor-cloud \
-H "Authorization: Bearer $INTEGRATION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"readings": [
{"serial": "A-100", "ts": "2026-10-27T09:00:00Z", "temperature": 21.4, "battery": {"voltage": 3.1}},
{"serial": "A-101", "ts": "2026-10-27T09:00:00Z", "temperature": 19.8, "door": false}
]}'
An array carries at most 100 items. A body that cannot be read, or an item with no identifier, is refused whole with 400 and nothing is stored. Items for devices the tenant does not have are skipped one by one and listed as unregistered; the rest are stored.
Without timePointer, the platform stamps an item with the time it received it, so the same item sent again looks like a new one. Set timePointer to a field the sender fills in once per reading, and a retry carries the same time and is stored once. See Duplicates and retries.
Generic binary
A generic binary integration accepts a body of raw bytes, with the identifier in a header or a query parameter (idFrom is header: or query:; a binary body has no fields to point into). The platform has no decoder for the bytes in v1, so every such uplink is accepted, counted as undecoded and its first 512 bytes kept as a sample on the integration. Use it to see what a sender sends before a decoder exists, not to store readings.
What is stored
The decoded object each message carries (ChirpStack's object, The Things Stack's decoded_payload, a generic item) is flattened into measurements of the device. One message becomes one event with these readings:
- Every number is a reading, keyed by its dot-joined path:
{"temperature": 21.4, "battery": {"voltage": 3.1}, "sensors": [{"value": 7}]}storestemperature,battery.voltageandsensors.0.value. - A boolean is stored as
1for true and0for false. - A string or a
nullis skipped. So is any value nested deeper than 32 levels, and any path that is not a valid measurement key (letters, digits,_,.and-, at most 128 characters, not starting with.or-). Nothing is renamed to make it fit. - Declare a metric with the same key to give a reading its unit and validation. A reading with no declared metric is stored without a unit.
- The message time is the sender's when it gives one, and the platform's receipt time otherwise.
- A message that flattens to more than 256 readings is refused whole with
400, not truncated.
With Store link metrics on, three more readings are stored with each uplink from the network server's reception report: lorawan.rssi and lorawan.snr for the strongest reception, and lorawan.gateways, the number of gateways that heard the uplink. These names belong to the platform; a decoded value with the same key is skipped.
Uplinks with nothing to store
An uplink that carries no decoded object, or one with no numeric or boolean value in it, is acknowledged but stores nothing. The integration counts it as undecoded, and its page shows the count, when the last one arrived and a sample of the raw frame in hex. The usual cause is a device profile with no codec in the network server. The IntegrationUndecodedHigh alert tells an operator when most traffic is like this; see Observability.
Duplicates and retries
A network server that does not get an answer in time sends a message again, and a gateway can deliver one uplink twice. Each message carries a key the platform stores with the event, and an event whose key is already stored for that device and time is not stored again.
| Sender | The key |
|---|---|
| ChirpStack v4 | The deduplicationId. |
| The Things Stack v3 | The device's DevEUI (its device id, when the integration identifies devices by DEVICE_ID and the message carries no DevEUI), the frame counter and received_at. |
Generic, with timePointer | A digest of the device and the item, plus the sender's time. A retry carries the same item and time, so it is stored once. |
Generic, without timePointer | A digest of the device and the item, plus the time of receipt. A retry is a new key, so it is stored twice. |
The platform also drops a repeat that carries the same key within ten minutes of the first, before it is processed; a generic sender without timePointer gets a new key on every send, so this does not cover it. A key is at most 192 bytes; a longer one is refused with 400. Neither rule merges two different uplinks that happen to hold the same values.
Unregistered devices
When a message names an identifier that no device in the integration's scope carries, the platform does not store it. It records the identifier on the integration instead, so you can see which devices are talking to you that you have not added. Open the integration in the console and look under Unregistered devices seen: each row shows the scheme, the identifier, how many times it was seen and when. Create a device with this alias opens the device form with the alias filled in, and the type too when the integration is scoped to exactly one device type. Over GraphQL, the list is unregisteredIntegrationDevices(criteria).
- The list holds at most 1,000 identifiers per integration. Past that, new ones are counted and not listed.
- A sighting is recorded at most once a minute for each identifier on each
event-sourcesreplica, so the count is a measure of activity and not an exact message count. - An identifier that a device outside the integration's scope already carries is not listed, so the list never shows a value a device already holds.
- Clear list (
clearUnregisteredIntegrationDevices(integration)) empties it. It is a list of observations, so an identifier the sender is still sending is listed again.
What the sender is told
A network server sees only the status code, and some suspend a webhook that is refused too often. The route is built around that.
| Answer | Meaning |
|---|---|
202 | Accepted. Includes messages for devices that are not registered, which are skipped, and messages shed because the tenant is over its ingest allowance. |
204 | A message type that is not an uplink (join, status, acknowledgement). Acknowledged and ignored. |
400 | The body is not what the integration's sender type expects: invalid JSON, a missing or malformed identifier, a deduplicationId that is not a UUID, a time older than 366 days, more than 100 items, or more than 256 readings. Nothing in the request is stored. |
401 | The token is missing, wrong, or belongs to a different integration. A wrong token and an integration that does not exist are indistinguishable on purpose. |
403 | The integration is switched off. This is not counted as a failed login. |
405 | The method is not POST. |
413 | The body is over 256 KiB. |
415 | ChirpStack is sending Protobuf. Change its payload encoding to JSON. |
429 | Too many failed logins for this integration, too many requests from one address whose token the platform has not just verified, or, on one replica, too many requests for integrations it has never verified. Retry after the Retry-After interval. |
503 | The platform cannot check the token or take the message right now, or is applying backpressure. Retry. A Retry-After header means the platform is applying backpressure: a single uplink was not stored, but in a generic array the items before the refusal may have been, so give a generic sender a timePointer to make the retry safe. |
A change to an integration is quick but not instant at the endpoint, which remembers what it learned about an integration for up to a minute (up to 15 minutes while device-management is unreachable). After you rotate a token or switch an integration off, it can keep answering 202 for up to a minute to a sender that is still using the old one, but nothing sent in that minute is stored. After you switch an integration back on, it can keep answering 403 for up to a minute.
Limits
| Limit | Value |
|---|---|
| Request body | 256 KiB |
| Decoded object, or one generic item | 64 KiB, nested at most 32 levels |
| Items in a generic array | 100 |
| Readings in one uplink | 256 |
| Age of a time the sender reports | 366 days |
| Integrations per tenant | 5 by default, set by the tier, at most 100 |
| Aliases on a device | 8, one value per scheme |
| Unregistered identifiers listed per integration | 1,000 |
The tenant's ingest allowance. Integration traffic is metered in an allowance of its own at the tenant's ingest ceiling, counted in readings like every other transport, apart from the allowances MQTT and HTTP ingest spend. The allowances are separate, so a tenant that sends over several paths at once can be admitted above one ceiling in total, on each event-sources replica; see When ingest can admit a tenant above its ceiling. A request carrying an array of N uplinks is charged N times, so a big array does not buy a cheaper rate.
Over the allowance, the answer is 202, not 429. A refusal would lose the message anyway, and some network servers suspend a webhook that is refused, which would then drop traffic that was within the allowance. The consequence is that the sender believes the data arrived and it did not. The IntegrationShedAccepted alert tells an operator when this is happening; raise the tenant's ceiling or reduce what the integration sends.
Bounds on unauthenticated traffic. Someone who knows an integration's URL cannot use it to spend the tenant's allowance or an integration's good traffic: wrong tokens are limited per integration (a burst of 30, then 1 a second), requests whose token the platform has not verified in the last minute are limited per source address (a burst of 60, then 5 a second) and, for integration names it has never verified, per replica (a burst of 100, then 20 a second). The ingress-nginx front end also limits each address to 200 requests a second and answers 503 beyond that; it limits per client only when the controller is told to trust the load balancer's forwarded address, and the Gateway API front end has no such limit. A network server's steady traffic with a valid token costs its address about one request a minute against the platform's own limits. The IntegrationAuthFailuresSustained alert fires when failures keep up for ten minutes, which is what a sender still using a rotated token looks like.
Commands to integration devices
A device that reports through an integration has no connection to DeviceChain to send a command down, and v1 has no downlink to a network server's API. A command to such a device is accepted when you issue it, and then fails at the next delivery sweep with COMMAND_TRANSPORT_UNSUPPORTED, because the platform recognizes the transport and has no way to deliver on it. It does not wait in the queue until it expires.
Two cases are worth knowing:
- A device that also connects over MQTT is judged by the last transport it reported on. While it last reported through the integration its commands fail; after it reports over MQTT they are delivered.
- A device that has never reported has no recorded transport, so its commands are dispatched as usual, and expire unanswered, until it first reports.
For a device that must be commanded, give it an MQTT connection as well. See the transport matrix.
What an integration token can do
Treat the bearer token like a password for a whole fleet. An integration token speaks for every device of its scoped types that carries an alias for it. The platform checks that the token belongs to the integration and that the device's type is in the integration's scope, and it does so again when the event is processed, but it cannot tell whether the sender is honest about which device a message came from. Anyone who holds the token can submit readings as any aliased device in scope.
So:
- Scope an integration to the device types it needs, and use one integration per network server or per fleet rather than one for everything.
- Rotate the token if it may have been exposed, and when someone who knew it leaves.
- The token is stored only as a keyed digest and is never shown again, by the console or by GraphQL. An administrator cannot read it back either.
- Integration events are marked with the integration they came through, which a device cannot imitate: a device cannot send an event over MQTT or HTTP that claims to have come through an integration.
- The Integrations page shows when a request last failed authentication, and flags a rejected token.
See the security model.
Not in v1
- Decoders you write. The platform stores what the network server decoded. There is no place to write your own decoder in DeviceChain yet, so the codec has to run in ChirpStack or The Things Stack, or in your generic sender.
- Downlink. Commands cannot be sent to a device through a network server. See Commands to integration devices.
- Signed payloads. There is no HMAC or signature check on a request. The bearer token is the only credential, and the transport must be TLS.
- Join, status and location messages. They are acknowledged and ignored.
- Automatic device creation. An unknown identifier is listed, not created. You add the device yourself, from the list.
- Decoding a generic binary body. It is accepted and counted, not decoded.
Troubleshooting
| What you see | Likely cause |
|---|---|
The sender reports 401 | The token is wrong or was rotated; the integration token in the URL does not match the integration the bearer token belongs to; or the tenant segment is wrong. The integration shows Rejected token. |
The sender reports 403 | The integration is switched off (Accepting data). |
The sender reports 415 | ChirpStack's payload encoding is Protobuf. Change it to JSON. |
The sender reports 400 | Read the response body; it names the field. Common causes are a DevEUI that is not 16 hex digits, a deduplicationId that is not a UUID and a time more than 366 days old. |
The sender reports 202 but nothing is stored | The device has no matching alias, or its type is outside the integration's scope: look under Unregistered devices seen. Or the uplink had nothing to store: look at the undecoded count. Or the tenant is over its allowance: see IntegrationShedAccepted. |
| Uplinks with nothing to read keeps growing | The device profile in the network server has no codec or payload formatter, or its output has only strings. |
| Values are stored with no unit | No metric is declared for that key on the device's profile. The key is the dot-joined path (battery.voltage). |
| A hosted network server cannot connect | The instance serves a self-signed certificate. Use a certificate from a public CA. |
| Every sender shares one rate limit | The proxies in front of event-sources are not trusted. See Client addresses and trusted proxies. |
Related
- Devices: the device form, with its Aliases field.
- Connecting a Device: the MQTT and HTTP paths a device uses itself.
- Transport Capability Matrix: what each way in does and does not support.
- GraphQL API reference: the integration operations.