GraphQL API
Every DeviceChain service that exposes an external API does so through GraphQL. This page lists the endpoints, how to get the schemas, the conventions every mutation follows, and the limits every request is held to.
Schemas evolve while DeviceChain is pre-release. The published schema files are the authoritative reference. Introspection is disabled by default (see Exploring the schema).
Download the schemas
Every schema is published here, generated from the files the services parse at startup:
| Reference pages | GraphQL schema reference — one page per schema: every query, mutation, subscription and type, with its description |
| Index | /schema/index.json — every area, its auth plane, its endpoint and its schema file |
| Schemas | /schema/<area>.graphql, plus -admin and -settings for the two areas that serve those planes |
Start with the index. It names the auth plane each schema sits on, and the token that plane takes, for every area in one place. Each published schema file also opens with a comment naming its own plane, endpoint and token. The plane matters because an admin mutation offered to a tenant developer is a call they can never authorize.
The files are served as plain text with permissive CORS, so you can fetch them directly:
curl -s https://docs.devicechain.io/schema/index.json | jq '.areas[] | {area, endpoint}'
curl -s https://docs.devicechain.io/schema/device-management.graphql
Endpoints
The ingress routes /api/<area>/graphql to each functional-area service and strips the prefix, so
the request reaches that service's own /graphql. Every endpoint below is therefore
https://<your-host>/api/<area>/graphql:
| Area | Covers |
|---|---|
user-management | authentication — login, selectTenant, refresh — and the tenant's own governance view |
device-management | devices, device types, profiles, assets, areas, customers, groups, relationships, alarms, credentials, detection-rule authoring |
event-management | time-series event queries — events, locationEvents, measurementEvents, alertEvents, bucketedMeasurements |
device-state | live last-known state — latestMeasurements, latestLocation, deviceStates — plus demoteAssertedPresence, which returns an event source's asserted devices to inferred presence |
command-delivery | command dispatch — createCommand, cancelCommand, fleet-wide batches (createCommandBatch, cancelCommandBatch), command history |
event-processing | detection-rule validation, replay preview, rule health |
dashboard-management | dashboard CRUD and versioning |
outbound-connectors | per-tenant outbound connector CRUD |
notification-management | notification channels and policies |
ai-inference | a single call, inferRuleCandidate, backing natural-language rule authoring — present only when the optional inference service is enabled |
Three more endpoints sit on a separate identity-token plane rather than the tenant plane. They are authorized for the superuser or operator:
| Endpoint | Covers |
|---|---|
/api/user-management/admin/graphql | the instance admin API — identity directory, memberships, role catalog, tenant registry and tiers |
/api/user-management/settings/graphql | instance settings |
/api/ai-inference/admin/graphql | operator-registered inference providers |
Authorization across the data-plane services is capability-based: each resolver checks for a
specific authority (for example device:write) carried on the caller's tenant token. A few
authorities do not line up with intuition:
- Reading device credentials requires
device:write, notdevice:read. latestLocationrequireslocation:read, while itsdevice-statesiblings requirestate:read.demoteAssertedPresencerequiresstate:demote, which is neither of those. No role names it by default; only a role holding the*super-authority, such as the seededtenant-adminrole, has it without an explicit grant. It is the only thing outside the event pipeline that writes the live-state projection, and one call reaches an entire event source's devices.
sparkplug-ingest and lwm2m-ingest serve no GraphQL at all and are deliberately kept off the
/api router entirely. event-sources is routed but answers with a placeholder schema: ingest
reaches it over the device-plane transports, not this API. update-management is routed too: its API is the artifact
catalogue (createArtifact, artifact, artifacts, retireArtifact) and the assignment of an available artifact to a
device (assignArtifact, cancelUpdate, updateAssignment, updateAssignments). An assignment carries
an update attempt whose state moves from QUEUED through INITIATED, DOWNLOADING, DOWNLOADED, VERIFIED, UPDATING and
REBOOTING to UPDATED, or to FAILED, TIMED_OUT, UNKNOWN or CANCELLED; UPDATED is reached only
when the device reports the target version running, on a new boot when the update needs a reboot. Assigning
an artifact that no active signer has signed is refused unless the instance sets
updates.allowUnverifiedArtifacts, which is shown in updateManagementInfo. Signers are public keys
registered with registerArtifactSigner and revoked with revokeArtifactSigner (both need
artifact:trust); addArtifactSignature attaches a signature that is verified against the artifact's
digest. An update left UNKNOWN
(the device went quiet during installation or reboot) blocks a new assignment for that component until the
device reports what it runs; if it never will, abandonUnknownUpdate(assignmentToken, reason) ends the update
as CANCELLED with your reason recorded, journals the change under your name, and tells the device. It does not
find out what the device runs, which may be either version, so check its inventory before relying on either.
Over-the-air updates describes the lifecycle and the settings. The artifact content itself is not sent
through GraphQL: it is uploaded with PUT /api/update-management/updates/artifacts/{token}/content,
authenticated with a tenant access token that holds artifact:write. The chart gives that route an Ingress of its own, with the request-body limit set by
ingress.uploadMaxBodySize (default 1g) and request buffering turned off. Behind any other ingress
controller set the same two things on that path: for ingress-nginx, nginx.ingress.kubernetes.io/proxy-body-size
at or above your largest artifact, and nginx.ingress.kubernetes.io/proxy-request-buffering: "off". Without the
second, nginx reads the whole body to disk before the service sees the request, so the token is not checked
until the entire image has arrived.
A device fetches an assigned artifact from GET /api/update-management/updates/download/{artifact}?cap=....
The request carries no tenant credential: cap is a capability the platform sent the device over its
authenticated MQTT session, naming one artifact for one device under one assignment, and it expires within
fifteen minutes. A device that needs longer asks again and is given a new capability for the same artifact.
Because cap is in the query string, the chart renders this route on an Ingress of its own with the
ingress-nginx access log turned off; behind any other ingress controller, make sure the query string of
/api/update-management/updates/download/ is not written to an access log.
The endpoint answers Range requests with 206, so an interrupted download resumes where it stopped, and it
refuses a forged, expired or no-longer-current capability before it reads any of the artifact. Set
updates.downloadBaseUrl to the public URL devices reach the service at (for example
https://<ingress host>/api/update-management); without it no device can be given a capability.
Subscriptions over WebSocket
A service that offers GraphQL subscriptions also accepts a WebSocket on its GraphQL endpoint. The
client must negotiate the graphql-transport-ws subprotocol and send its access token in the
connection_init payload, as {"Authorization": "Bearer <token>"} or {"token": "<token>"}. The
token is checked once, when the connection opens.
- The WebSocket runs subscriptions and nothing else. A query or mutation sent over it is refused
with the error
only subscription operations are accepted over a WebSocket; send queries and mutations over HTTP, and nothing runs. Send queries and mutations as HTTP requests. - The connection closes with code
4401when its access token expires. To keep a feed running, open a new connection with a fresh token and subscribe again.@devicechain/clientdoes this once on its own: when a connection it had established is closed with4401, it reconnects with a newly resolved token, subscribes again, and reports the reconnect to your sink asconnected(true). The .NET SDK raises the close fromSubscribeAsyncas an exception that names the code; subscribe again to continue. - A service with no subscriptions refuses the upgrade with HTTP 400 (
this service offers no GraphQL subscriptions).
A subscription the server cannot read gets a syntax error, and a document that names an operation it does not hold gets its own error. Both apply to that operation only, and the connection stays open.
Querying events
event-management exposes read queries over the persisted event history. Each takes a search
criteria — device, event types, an occurred-time range, a relationship anchor ({type, token}), and
pagination — and returns paginated results:
query {
measurementEvents(criteria: {
pageNumber: 1, pageSize: 50,
deviceToken: "sensor-001",
startTime: "2026-06-01T00:00:00Z",
endTime: "2026-06-24T00:00:00Z",
anchor: { type: "customer", token: "acme-corp" }
}) {
results { deviceToken occurredTime name value }
pagination { totalRecords }
}
}
- Entities are named by token throughout, including inside the anchor.
- Both time bounds are inclusive, and they filter on
occurredTime: the instant the device reported, not the instant the platform stored it. - Results come back newest first.
- Pagination is 1-based.
All event queries are tenant-scoped automatically: results are limited to the caller's tenant, and a query without a resolved tenant is rejected.
An event's processedTime is when the platform received it. For MQTT on the platform's broker,
this is when the broker stored the message, which after an event-sources outage can be well before
the event was processed. For other transports, it is when the ingest service took the event in.
measurementEvents does not filter by measurement name. Its criteria has no name field, so
"only the temperature readings for this device" is not directly expressible. Either filter
client-side on results[].name, or use bucketedMeasurements, which does take a name and returns
time buckets:
query {
bucketedMeasurements(criteria: {
deviceToken: "sensor-001",
name: "temperature",
startTime: "2026-06-01T00:00:00Z",
endTime: "2026-06-24T00:00:00Z",
intervalSeconds: 300
}) { bucketStart name avg min max sum count }
}
bucketedMeasurementsA reading written now but stamped more than 30 days in the past (by its own occurredTime,
which a device controls) is returned by measurementEvents but not by bucketedMeasurements, and no
error says so. This only reaches a device that buffered for over a month, or one whose clock is wrong
by that much. See Backfilled readings and the rollup.
Backfilled readings and the rollup
A bucketed read whose intervalSeconds is a whole multiple of 60 and which carries no anchor filter
is served from a pre-aggregated rollup rather than from the raw readings. The rollup keeps itself
current over a trailing 30-day window. Everything older than that was materialized once, when the
database was created.
A reading written now but stamped more than 30 days back falls between the two: too old for the
refresh window, too late for the one-time pass. The raw history is complete, so measurementEvents
returns it; bucketedMeasurements does not show it.
The boundary is how far back the reading is stamped, not how old the data is. A reading backfilled by an hour, a day, or three weeks is picked up within a minute and is fine. Sub-minute intervals and anchor-scoped reads are served from the raw readings and are unaffected.
Exploring the schema
Introspection is disabled by default. A production deploy that sets nothing exposes no introspection surface. If you point a GraphQL client at an endpoint and expect it to self-document, the introspection query is rejected.
That leaves two ways to read the schema.
The published schema files, listed under Download the schemas. This is
the reliable route: it needs no running instance and no token, which matters most while you are
still evaluating DeviceChain. The files are generated from each service's schema sources on every
docs build, so they cannot drift from the schemas the services parse. You can also read the
committed sources in the repository. Every one is a .graphql file named for the endpoint that
serves it: schema.graphql for the tenant API, plus admin_schema.graphql and
settings_schema.graphql in the areas that also serve an identity-token API.
Introspection on a development instance. Set DC_GRAPHQL_DEV_TOOLS=true on the service to
enable it. Do this on a dev instance only; it is off by default deliberately. Any value that does not
parse as a boolean is treated as disabled rather than guessed. With it enabled, the usual query
works:
query {
__schema {
types { name kind }
}
}
Enabling dev tools also serves a GraphiQL explorer at /graphiql on each service. Through the
ingress that is /api/<area>/graphiql; on a port-forward straight at the pod it is /graphiql. The
explorer posts to the endpoint it was reached through, so it works on all three routes: ingress,
port-forward, and the console's dev proxy. Before v0.12.0 the page loaded and then failed every
query it sent, because it pointed at a path no service serves.
Conventions
- Entities are addressed by a human-readable token in addition to an internal id.
- List queries take a search-criteria input with pagination.
- Mutations follow a
create* / update* / delete*naming pattern.
How much of a record an update writes
Every update* mutation is a partial update, and there is only one contract. Each takes a
dedicated *UpdateRequest, never the create* sibling's input, and each distinguishes three states
rather than two:
| What you send for a field | What happens to the stored value |
|---|---|
| Nothing — the field is absent | Left alone |
An explicit null | Cleared |
| A value | Set to that value |
Individual fields can still deviate: a required reference that refuses to be cleared, a write-only secret, a field that is not in the update input at all. Those are listed in Where the default does not hold. Read it before you automate anything.
A rename is only a rename:
# Changes the name. The description, the externalId, the metadata and the device's
# type are all left exactly as they were, because none of them is mentioned.
mutation {
updateDevice(token: "sensor-001", request: { name: "Cold store probe" }) {
token
name
}
}
Send only what you mean to change. Reading the record and posting the whole thing back is the
habit a full-replace API teaches, and it is the wrong one here. It is more work, it widens the window
in which you overwrite a concurrent edit, and on a write-only secret field it is actively
destructive — see the warning below.
A partial update narrows concurrency conflicts without removing them. Two writers who touch
different fields no longer overwrite each other, but two who touch the same field still do.
updateDashboard, updateConnector, updateAiProvider and updateNotificationPolicy take an
optional expectedUpdatedAt and refuse the write when the stored timestamp has moved since you read
it. Pass the updatedAt you last read, or omit it for last-write-wins. The updatedAt a create*
response carries is not one to send: read the record back first, or the first guarded update may be
refused as stale although nothing changed it.
The token argument names the record
Every update* names the record by an argument, never by the payload, and that argument
decides which record is written. On all of them but two it is token: String!; updateRole
also takes scope: String!, and names the role by scope and token together. updateOauthClient
takes clientId: String! instead, and updateProfile takes no locator at all, because the record
it edits is the signed-in identity.
The payload carries no token. On every partial update, the
input has no token field, so a payload token that disagrees with the argument is unrepresentable:
the schema rejects it.
Two other behaviours existed before and are gone:
- A payload token that had to agree with the argument — refused when it disagreed, read as "unspecified" when empty. Its last two mutations have converted.
- A payload token that named the record's new token, which is how a profile, a connector, a provider and a notification channel were renamed. All four now have a rename mutation of their own, and with that the last update input on the platform that carried a token is gone.
Every update now agrees on two things: a payload token can no longer blank a record, and it can
never make the mutation write a record other than the one token: names.
Renaming a record
Four records used to be renamed by sending a different token inside a full-replace update payload. Each now has a mutation of its own, where the new token can mean only one thing:
renameDeviceProfile(token: String!, newToken: String!): DeviceProfile!
renameConnector(token: String!, newToken: String!): Connector!
renameAiProvider(token: String!, newToken: String!): AiProvider!
renameNotificationChannel(token: String!, newToken: String!): NotificationChannel!
All four follow one contract:
- A blank
newToken(empty or whitespace-only) is refused, because it would leave a live record addressable by nothing. - Renaming a record to the token it already has is an idempotent success that returns the record, so retrying after a partial failure is safe.
- A token another record of that kind already holds is refused by name, with
extensions.codeset toCONFLICT, whether the refusal comes from the lookup or from a concurrent rename that got there first. See A value that must be unique. - The required authority is the one the matching update takes: a rename is an edit of the record, not a new kind of act.
Each of these renames was always intended, because what depends on the record keys on its internal id rather than its token. That covers a channel's delivery secret and the channel id a policy's rules store, a connector's credential, and a provider's API key along with its tier grants and every tenant's model assignment. A rename orphans none of them.
Two things a rename does still move; check both before you issue one:
- A rule's actions name their connector by token, so rules pointing at a renamed connector have to be re-pointed.
renameDeviceProfilerefuses a rename outright once the profile has been published or adopted by a device type, because from that point published rules and device rosters name it by token.
updateNotificationPolicy needed no rename mutation: nothing keys on a policy's token, so you move a
policy by creating the new one and deleting the old.
A geofence's token is immutable. updateGeoFence used to reconcile two tokens and refuse a
disagreement. Its input now carries no token, so no request can ask for a rename. The reason is
unchanged: detection rules name fences by token inside compiled expressions this service cannot
rewrite, so a rename would leave every one of them naming nothing while the mutation returned
success. If you need a fence under a different token, create the new one first and delete the old
one after. Doing it the other way round can forfeit position headroom you are grandfathered on and
leave the fence unrecreatable.
Before this release, token handling on updates was neither uniform nor safe, and both failures returned success. See How token handling changed.
How token handling changed
Most update* mutations located the record by the payload token and ignored the argument
entirely. A request naming one entity in token: and another in request.token silently updated
the second and returned it. The rest honoured the argument but then wrote the payload token over the
stored one, so the payload still moved the record. An empty payload token, which token: String!
permits ("" is a valid non-null String), blanked the record's token and left a live row
addressable by nothing.
A client that relied on the payload naming the record now gets an error rather than writing the
wrong row. A client that sends token: "" on an update now gets an error either way, where before it
destroyed the record's identity: the rename mutations refuse it, and on a partial update the schema
rejects it, because the input has no token field to send it in. A third set of mutations used to
ignore an empty token (that was the "must agree" rule); those have all converted.
Where the default does not hold
These are the field-level exceptions in the API this release serves. The last two rows describe a
kind of field and give examples rather than listing every one; each field's comment in the
published schema says whether it can be cleared. A field that is not an
exception follows the three states above: absent leaves it alone, null clears it, a value sets it.
| Field | What omitting it does |
|---|---|
secret on updateNotificationChannel, updateConnector, updateAiProvider | Kept. A value rotates it; null — or an empty string — deletes it. You cannot read a secret back, so omitting it is how you say "leave the credential alone". A notification channel whose webhook config declares bearer or header auth refuses to be left without one |
config on updateTenantTier | Kept. Clearing a tier's settings re-prices every tenant at it, so it is not reachable by omission — send null or {} to clear |
selector on updateEntityGroup | Kept when omitted. Unlike most partial-update fields it cannot be cleared: null is refused, because a dynamic group with no selector matches nothing and cannot be repaired. A static group is refused a selector outright |
definition on updateDashboard | Kept when omitted, which is how you rename a dashboard without resending its document. Like selector above it cannot be cleared: null is refused, because a dashboard with no definition is not a thing. A malformed one refuses the whole update, so a rename sent with it is not applied either |
credentialType on updateProvisioningProfile | Not in the update input. Provisioning can mint exactly one credential type today, so the field would only ever restate what is stored. It used to be reset to ACCESS_TOKEN by any update that omitted it |
activeVersion on a device profile or an entity group | Nothing: it is not writable here at all, and moves only by publish and rollback |
memberType / membershipMode on updateEntityGroup | Not in the update input. Both are identity, so a change is unrepresentable rather than refused |
A tenant's governance overrides on updateTenant | Kept. Sending null removes the override, which means inherit the tier and then the platform default — never zero, and never "unlimited" |
A field the record cannot exist without — for example type and config on updateConnector; kind, model and enabled on updateAiProvider; channelType and enabled on updateNotificationChannel; enabled on updateNotificationPolicy; tierToken on updateTenant; redirectUris and scopes on updateOauthClient; and the required references and fields listed under Fields worth knowing about | Kept when omitted. An explicit null is refused rather than cleared |
A field validated together with another — for example endpoint on updateAiProvider, and entityGroupToken / entityGroupVersion on updateDetectionRule | Kept when omitted. A null clears it only if the record is still valid with what the other field holds: endpoint: null is refused for a provider kind with no default address, and clearing one half of a rule's group scope without the other is refused |
For every write-only secret field, "" deletes the stored credential, and the mutation returns
success. (The one exception is a notification channel whose webhook config declares bearer or
header auth: there the whole update is refused instead.) A client that fills in every field deletes a credential it never meant to touch, and a
connector whose credential is gone fails authentication on every outbound dispatch. Leave the field
out. See Secrets and empty strings.
Secrets and empty strings
You cannot read a secret back, so there is nothing to re-send; the API's answer is that omitting it
keeps it. "Read the record, change one thing, send it all back" is the habit a full-replace API
teaches, and clients written against one still do it. Filling in every field means sending
secret: "" for a credential you never meant to touch, which deletes it.
null deletes the credential too. That is the platform's ordinary meaning of a null rather than an
exception: a null clears the field it names. The inversion these fields used to carry, where
null preserved and only "" deleted, is gone.
Text is trimmed; credentials are not
A name, a description, and similar display text — a person's first or last name, an icon, a unit,
a tier's color — is stored without its leading and trailing spaces, and an empty or
whitespace-only value clears it, so it reads back as null. That happens on create and on update
alike, so sending back a value you read is not a change for anything stored under this rule. A
value saved with surrounding spaces by an earlier release, such as a person's name, is trimmed the
first time an update names that field. Structured text, such as metadata, a
channel's config or a dashboard definition, is not trimmed.
A device credential's credentialValue is not trimmed either: it is stored exactly as you send
it, spaces included, because a device presents its password byte for byte. Only an empty
credentialValue, or an explicit null on update, stores no password, and a credential with no
password cannot authenticate.
Which mutations are partial updates
All of them. The conversion arrived one area at a time and is now complete. This section records what changed in each area, because a client written against the old behaviour needs to know.
device-management. Every update* takes a dedicated *UpdateRequest:
updateDeviceType · updateDevice · updateAssetType · updateAsset · updateCustomerType ·
updateCustomer · updateAreaType · updateArea · updateMetricDefinition ·
updateCommandDefinition · updateDetectionRule · updateGeoFence · updateEntityGroup ·
updateDeviceCredential · updateProvisioningProfile · updateEntityRelationshipType ·
updateDeviceProfile
Outbound connectors and AI inference. Each converted its one update: updateConnector and
updateAiProvider. Every rename channel those areas' payload tokens carried moved to a
dedicated rename mutation rather than being dropped. Both keep an optional
expectedUpdatedAt. On both, type/config and kind/endpoint respectively are validated as a
pair against the values the record will hold. Naming one of a pair re-checks the stored other,
and a change that would leave the record unusable is refused at the write rather than at first use.
notification-management. Both update* mutations have converted: updateNotificationChannel and
updateNotificationPolicy. Three things about the policy are worth knowing before you send one:
rulesis optional, and omitting it leaves the rule set exactly as it is — the same rows, not a rebuilt copy of them. It used to be required, and every update replaced the whole rule set, so an edit that only changed a name destroyed and recreated every rule, and an edit that leftrulesout emptied the policy and returned success. Whole-replace is still available: send the list. Sendingnullor[]empties the rule set; for a list those are one request spelled two ways.deviceTypeTokenis not in the update input at all. A non-empty value is refused at write, because the dispatcher skips a device-type-scoped policy, so accepting one would return success on a policy that delivers nothing. That left the field with no request it could accept beyond a no-op. It stays on the create input, where the refusal explains itself.- It takes an optional
expectedUpdatedAt. Send theupdatedAtyou last read, or the one an update returned, and the update is refused, with nothing written, if anyone has changed the policy since — its rules included. Leave it out and the last write wins, as before.
dashboard-management. updateDashboard takes a DashboardUpdateRequest and carries no token at
all. Its one wrinkle is definition: the field is nullable so it can be omitted, which is how you
rename a dashboard without resending its whole document, but an explicit null on it is
refused, because a dashboard with no definition is not a thing. It keeps its optional
expectedUpdatedAt precondition. An update that names no field at all writes nothing (not even
updatedAt), while a stale precondition on it is still refused as a stale write (with no
extensions.code).
user-management. Every update* takes a dedicated request too:
updateRole · updateTenant · updateTenantTier · updateOauthClient · updateProfile
That is the whole update surface. No update* mutation anywhere takes a create* sibling's
input, so this page no longer carries a list for you to check a mutation against. Earlier releases
did, twice: first a roster of the areas that had not converted, then a rule saying the signature was
the authority because two contracts coexisted. Both went stale when their premise expired. What
replaced them is the three states and the
field-level exceptions, which describe the whole API.
One contract does not mean every input takes every field. Some fields are absent from their
*UpdateRequest on purpose, and others accept a value but refuse a null. The
schema you downloaded is the authority for the first; the
exceptions table, with each field's schema comment, covers the
second. See What an update input can express.
What an update input can express
What an update can express is what its *UpdateRequest declares. Some fields are deliberately
absent — deviceTypeToken on updateNotificationPolicy, memberType on updateEntityGroup,
credentialType on updateProvisioningProfile — because no request for them would be accepted.
Others accept a value but refuse a null; the
exceptions table describes them, and each one's schema comment
says so. Neither is a question about which contract the mutation is on, because there is only one.
Four user-management updates used to write every field their input declared. See
user-management changes before re-running an old client, starting with
updateTenant.
user-management changes
updateRole, updateTenant, updateTenantTier and updateOauthClient used to write every field
their input declared, so a request naming only name blanked the rest and returned the emptied
record. Re-check updateTenant first: omitting a governance override used to erase it, so
renaming a tenant removed every ceiling an operator had set. Omitting one now leaves it alone, and
only an explicit null removes it.
tierToken on updateTenant became optional. Omitting it keeps the tenant at its current tier;
an explicit null is refused, because every tenant has a tier.
authorities, redirectUris and scopes became nullable lists ([String!], not [String!]!),
so they now have an absent state:
- Omitting one leaves it alone.
- Sending a list replaces it wholesale.
nulland[]both mean "empty".
A role's authorities may be emptied, because a role that grants nothing is a thing you can create. An OAuth client's redirect URIs and scopes may not: an empty redirect allowlist matches nothing, so the client could never complete an authorization.
updateProfile now takes request: ProfileUpdateRequest! instead of bare firstName / lastName
arguments. It writes only the names you send. A name is trimmed like other display text, and "",
a whitespace-only value or null clears it, so it reads back as null.
Fields worth knowing about on the converted mutations
- A required reference cannot be cleared.
updateAsset'sassetTypeToken, and its peers on devices, customers and areas, re-point the entity when you send one and leave it alone when you do not. An explicitnullis refused, because "no type" is not a state those entities can be in. An unknown token is refused too, and the refusal is total: nothing is written. updateDeviceType'sprofileTokencan be cleared, because a device type with no device profile is a real thing. Under the old full-replace shape, omitting it while renaming a type detached the profile, which silently un-declared position for every device built on the type and returned success. Omitting it now keeps the current profile;null, or an empty token, detaches it. A detection rule's optionalentityGroupTokencan be cleared too, but only as a pair: sendnullfor bothentityGroupTokenandentityGroupVersion. Clearing one and leaving the other set is refused, because a scope needs both.- A required field cannot be cleared either, even when it is not a reference. A metric's
dataType, a credential'scredentialTypeandenabled, a rule'sdefinitionandenabled, a fence'sgeometry, a provisioning profile'sprovisionKeyandprovisionSecret: send a value to change one, omit it to leave it alone, and an explicitnullis refused. The failure this prevents is invisible: foldingenabled: nulltofalsewould disable a credential or park a rule and return success, andfalseis a value you could legitimately have sent. - Omitting a secret now keeps it.
credentialValueonupdateDeviceCredentialandprovisionSecretonupdateProvisioningProfileused to be blanked by any update that failed to restate them. That took a device, or a whole self-registering fleet, offline at its next connection, with a200on the edit that broke it.
metadata is replaced wholesale when you send it, and cleared by null. It is an opaque JSON string
in the schema rather than a map, so there is no per-key merge to choose between: the API has never been able to address an individual key.
Input validation
An input field the schema does not define is rejected. Sending an undeclared field fails the whole request with an error that names the offending field and suggests the declared field you probably meant:
{
"errors": [{
"message": "Variable \"request\" has invalid value.\nField \"deviceProfileToken\" is not defined by type \"DeviceTypeCreateRequest\". Did you mean \"profileToken\"?"
}]
}
This holds whether the value is written as a literal in the query or supplied through a variable.
It matters more than a typo check. A silently discarded field is indistinguishable from one that was applied: the mutation returns success, and you get a partially configured entity with nothing to indicate a value went missing. Rejecting it is what makes a success response mean the whole input was understood.
What a token may contain
Every entity token, and every tenant id, must match:
^[A-Za-z0-9][A-Za-z0-9_-]*$
That is letters (either case), digits, hyphens and underscores, starting with a letter or a digit, and at most 128 characters. Anything else is refused at write, on create and on update, before anything is stored.
This is a security rule rather than a house style, which is why it is this narrow. A token is
spliced into infrastructure namespaces: a tenant id becomes a segment of a NATS subject recovered by
splitting on ., and a device token becomes a segment of an MQTT topic. A . shifts subject
segments, and *, >, + and # inject wildcards that match across tenants. Uppercase is
allowed deliberately, because machine-supplied identifiers like device serials and VINs are usually
uppercase.
The identifiers integrators reach for first are the ones this rejects: sensor.001, a MAC address
AA:BB:CC:DD:EE:FF, plant/line-2, anything with a space. Put those in externalId instead. It
is opaque, has no format constraints, and is unique within a tenant when present. Give the entity a
token you choose and keep the device's own identifier alongside it.
The console mints tokens for you from a per-entity-type template, so this rarely comes up there. It bites first on the API and in scripted provisioning.
A value that must be unique
Some values must be unique: a token within its tenant, a device's externalId, a command key
within a profile, an identity's email, one membership per identity and tenant. A create, update or
rename that would repeat one is refused, and the error carries extensions.code set to CONFLICT:
{
"errors": [{
"message": "the request conflicts with an existing record: a value that must be unique is already in use",
"path": ["createDeviceType"],
"extensions": { "code": "CONFLICT" }
}]
}
Branch on the code, not on the message. Where the service has more to say, the message is its own sentence instead, for example a rename onto a token already in use, and the code is the same. The database's own wording for the collision is replaced by the sentence above, so the message does not name a database index or column. A service's own sentence may repeat the token you sent.
CONFLICT means the write collided with a value that must be unique. That is usually a value you
sent, but it can be one the server assigns during the write, such as the next version number when
two publishes of the same record run at once, and then a retry succeeds. So the code does not by
itself mean that the record you asked for already exists. It means that only where the one unique
value involved is yours, such as the token of a tenant you are creating.
Some refusals look similar and do not carry CONFLICT:
- A save refused because the record changed since you read it ("modified by another writer; reload and try again") is a stale write, not a duplicate.
- A deleted tenant's token is reserved until the deletion finishes. Creating a tenant at it is
refused without
CONFLICT, because the token is not held by a tenant you could use. - Creating a command with a token already in use is not refused at all: you get the original command back. See Issue a command.
- Deleting a record that other records still refer to carries
REFERENCE_VIOLATION. See A reference or a value that is refused.
A reference or a value that is refused
Two other refusals are answered the same way, each with its own code.
REFERENCE_VIOLATION means the write was refused because of how records refer to each other.
Deleting a record that other records still refer to is refused this way: for example a device
profile a device type still uses, a device type its devices still use, a tenant tier that tenants
are still at, a tenant that still has memberships, an AI provider that is still granted, or a
notification channel a policy rule still names. The message is the service's own sentence, which
says what still refers to the record and may repeat the token you sent:
entity is still referenced and cannot be deleted: 1 device type(s) reference device profile "rover"
Remove or reassign what still refers to the record, then try again.
When the database refuses the write rather than the service's own check, the message is:
the request refers to a record that does not exist, or removes one that other records still refer to
That can happen when the records change between the check and the write, for example when a record your write refers to is deleted while the write runs. Reload and try again: you then get the service's own answer, or the write succeeds. If the same request keeps getting this answer, something you cannot see through the API still refers to the record. Report it with the time of the request; the server logs which reference refused it.
A write that names a record that does not exist is normally refused by the service's own lookup
before the write, with NOT_FOUND rather than with this code. It carries
REFERENCE_VIOLATION only when that record was removed while the write ran.
INVALID_VALUE means a value in the request is one the record does not allow, and the service did
not catch it before the write. The message is:
the request contains a value this record does not allow
Change the value. Sending the same request again normally gives the same answer.
In both cases the database's own wording is replaced, so these two sentences name no table, column
or constraint and repeat none of the values you sent. Neither code is CONFLICT, so code that
treats CONFLICT as "the record already exists" does not treat them as success. A refusal that
involves both a value that must be unique and one of these carries REFERENCE_VIOLATION or
INVALID_VALUE, never CONFLICT, and its message uses that code's sentence in place of the one for
a value already in use.
A record that does not exist
An operation refused because the record it names does not exist, or does not exist in your tenant,
carries extensions.code set to NOT_FOUND:
{
"errors": [{
"message": "record not found",
"path": ["cancelCommandBatch"],
"extensions": { "code": "NOT_FOUND" }
}]
}
Branch on the code, not on the message, which a service may prefix with what it was doing. A few
operations answer a missing record with a code of their own that says what is missing, such as
ARTIFACT_NOT_FOUND, and those keep their own code.
Request limits
Every GraphQL endpoint refuses a request that is too large or does too much, before any of it runs. The one exception is the limit on credential checks, which applies while the request runs (see Credential checks per request).
The limits are the same for every service, and you can change each one per service with the environment variable shown. A value that is missing, not a number, or below 1 falls back to the default: none of them can be switched off.
| Limit | Default | Variable | What is refused |
|---|---|---|---|
| Request body | 4 MiB | DC_GRAPHQL_MAX_BODY_BYTES | The whole HTTP body, including variables. Answered with HTTP 400. |
| Execution time | 60 seconds | DC_GRAPHQL_EXEC_TIMEOUT (whole seconds) | A query or mutation still running at the deadline is cancelled, and the database statement in flight with it; the response carries an error. Does not apply to subscriptions. A client must also deliver the request body within 30 seconds. |
| Query length | 100,000 bytes | DC_GRAPHQL_MAX_QUERY_LENGTH | The query string itself. |
| Nesting depth | 15 | DC_GRAPHQL_MAX_DEPTH | Selections nested deeper than this. |
| Root fields per query | 20 | DC_GRAPHQL_MAX_QUERY_ROOT_FIELDS | A query operation selecting more top-level fields than this. |
| Root fields per mutation | 5 | DC_GRAPHQL_MAX_MUTATION_ROOT_FIELDS | A mutation operation selecting more top-level fields than this. |
| Credential checks per request | 1 | DC_GRAPHQL_MAX_CREDENTIAL_CHECKS | Password checks in one request beyond this number (see below). |
Apart from the body limit and the credential-check limit, a refused request gets HTTP 200 with a
single entry in errors, no data, and nothing executed. The credential-check limit refuses only
the checks over it, each with its own error, and the rest of the request still runs. A root-field
refusal carries extensions.code set to TOO_MANY_ROOT_FIELDS:
{
"errors": [{
"message": "mutation (anonymous) selects 6 root fields; the maximum is 5",
"extensions": { "code": "TOO_MANY_ROOT_FIELDS" }
}]
}
Root fields are counted by response key:
- Every alias counts as a field of its own.
- Fields reached through a fragment count as if they were written out.
- Repeating the same key is one field.
@skipand@includeare not evaluated, so a conditional field counts whether or not it runs.- Every operation in the document is counted, not only the one
operationNameselects. - The rule applies over WebSocket as well as HTTP.
The mutation limit is the tight one because mutation fields run one after another. Without it, a
single request could carry hundreds of aliased copies of an expensive mutation. The console, the
dashboard app, the SDKs, dcctl and the MCP server send one mutation field per request and at most
two query fields. The limit applies to top-level fields only; aliases of a nested field are not
counted.
Size bounds on lists, inputs and aggregations
Separately from the request-shape limits above, some reads and inputs have a size bound. A request
over one is refused with extensions.code set to LIMIT_EXCEEDED. Nothing is truncated and nothing
is partly applied, so narrow the request and send it again. Sizes are counted in bytes, not
characters.
| Bound | Limit | Applies to |
|---|---|---|
| Attribute key | 128 B | setEntityAttribute attrKey. |
| Attribute value | 64 KiB | setEntityAttribute value, whatever its declared type. |
| Rule definition | 256 KiB | definition of a detection rule, on create and update. |
| Rule authoring graph | 256 KiB | authoringGraph of a detection rule, on create and update. |
| Version history | 1,000 per call | deviceProfileVersions, entityGroupVersions, assetTypeVersions: newest first. Narrow with limit (at least 1) and page with offset (not negative); a value outside that range is refused with INVALID_VALUE. |
| Buckets per aggregation | 10,000 | bucketedMeasurements: the range divided by intervalSeconds, rounded up. |
| Rows per aggregation | 50,000 | bucketedMeasurements: the buckets multiplied by the measurement names returned. With a name filter that is one name; without one it is every distinct measurement name that matches the read's filters. |
| Keys per lookup | 1,000 | A list of tokens, external ids or alarm tokens passed to a …ByToken, …ByExternalId or similar batch query (device states, latest locations, commands, command batches, notification channels, policies and states). |
| JSON input size | 64 KiB | Each metadata, properties, configuration, payload or recipients value sent as JSON. Command payloads and metadata are refused with PAYLOAD_TOO_LARGE / METADATA_TOO_LARGE (see Sending commands). |
| Recipients per notification rule | 100 | The recipients list of a notification policy rule. |
| OAuth client redirect URIs | 16 entries, 2,048 B each | redirectUris on createOauthClient and updateOauthClient. |
| OAuth client scopes | 16 entries, 64 B each | scopes on createOauthClient and updateOauthClient. |
| Tenant tiers | 100 tiers, 128 B per token | The tier catalog (createTenantTier refuses a 101st tier) and orderedTokens on reorderTenantTiers, which must name every tier. |
| Admin role and authority lists | 100 entries | authorities on createRole and updateRole; systemRoles on createIdentity; roleTokens on setSystemRoles, addMembership and setMembershipRoles. |
| Password | 72 B | password on createIdentity and setPassword: longer passwords are refused rather than silently truncated. |
bucketedMeasurements also requires startTime; endTime defaults to the current time.
A command key is limited to 128 bytes by the token grammar, which is also the longest command name that can be enqueued.
GraphQL syntax only
Documents must use GraphQL's own syntax. A document containing any of the following is refused with a syntax error, and nothing runs:
- A
//or/* */comment. Use#for comments. - A backquoted string, or a single-quoted character.
- A block string whose closing
"""directly follows a backslash (the\"""escape). That escape is valid GraphQL, but the server has never read it as the specification defines it, so send such text in a variable instead. - A string directly followed by a quote, such as
"x""y". GraphQL reads it as two adjacent strings, and the server used to read it as the start of a block string. Only"""opens a block string.
The same rule applies over WebSocket, where a subscription the server cannot read gets the syntax error rather than the subscriptions-only message.
Count ceilings on device-management
Two collections are read whole, so each has a ceiling on how many members it can hold. A write that
would take a collection past its ceiling is refused with extensions.code set to LIMIT_EXCEEDED,
and nothing is stored. Reads are never truncated: an owner already over a ceiling keeps every row
and every read returns all of them. Deleting rows makes room again.
| Ceiling | Limit | Applies to |
|---|---|---|
| Tracked relationships per device | 256 | Relationships of a tracked relationship type whose source is one device, on createEntityRelationship, createEntityRelationships and claimDevice, and on updateEntityRelationshipType when it sets tracked to true and that would put any device over the ceiling. One createEntityRelationships call carries at most 1,000 relationships. Event resolution reads all of a device's tracked relationships for every event. |
| Definitions per device profile | 1,000 per kind | Metric definitions, command definitions and detection rules, each counted on its own, on createMetricDefinition, createCommandDefinition and createDetectionRule. |
The service exports devicechain_devicemanagement_tracked_relationships_per_device (observed each
time a device's tracked set is loaded from the database) and devicechain_devicemanagement_profile_children
(labelled by kind: metric, command or rule) as histograms of the sizes it reads, so you can
see how close real data comes to a ceiling.
Credential checks per request
One request can have only a limited number of passwords checked, however it is written: one by
default. The login fields up to that number are evaluated as usual. Any further login in the same
request, such as another alias, is not evaluated: the password is not checked, nothing is looked up,
and nothing is recorded in the audit log. It gets its own error instead of a verdict on the password,
and the other fields in the request still return their data:
{
"errors": [{
"message": "this request has already made its credential checks; send one sign-in per request",
"path": ["a2"],
"extensions": { "code": "TOO_MANY_CREDENTIAL_CHECKS" }
}]
}
This does not depend on how the document is written, so it still holds for a document that gets past the root-field limit. The refusal happens before the email address is looked at, so it is the same whether or not an account exists. Every client DeviceChain ships sends one sign-in per request, so none of them is affected.
DC_GRAPHQL_MAX_CREDENTIAL_CHECKS raises the number per service; like the other limits, it cannot
be switched off. Refusals are counted on devicechain_usermanagement_credential_checks_total with
outcome="request_budget".
Sign-in backoff
Failed password sign-ins slow down further attempts on the same email address. This applies to
login and to the OAuth sign-in form.
- The first five failed attempts on an address are evaluated straight away.
- After that, the address waits 1 second before its next attempt is evaluated, then 2, then 4, doubling up to 5 minutes.
- A successful sign-in resets the count, and so does a quiet spell of 10 minutes after the last attempt that was evaluated.
The count belongs to the address that was typed, whether or not an account exists for it, so the delay does not reveal which addresses are registered. It is shared by every replica of the service.
While someone keeps sending wrong passwords for an address, its owner is refused as throttled even with the correct password. It ends when they stop: after at most one wait of up to 5 minutes, the owner can sign in again. See Holding an address at the backoff.
Holding an address at the backoff
The count is kept per address, not per address and network location, so that an attacker cannot get
a fresh allowance by spreading guesses across many machines. The cost is that anyone who knows an
email address can keep sending wrong passwords for it. While they keep that up, each evaluation slot
goes to them, and the owner is refused as throttled even with the correct password. It is not a
permanent lockout. The devicechain_usermanagement_credential_checks_total metric, with
outcome="throttled", shows when an account is being held this way.
OAuth client secrets
OAuth client secrets are not slowed down. Client secrets created by the admin API are 256 random bits, which no number of guesses will find, and a client ID is public: it appears in every authorization URL. A backoff on client secrets would not protect them. It would let anyone hold a confidential client, and so every sign-in through it, at the backoff. A client you seed from configuration should have a secret just as strong. Public clients have no secret at all.
Attempts during the wait
An attempt made during the wait is not evaluated at all: the password is not checked and nothing is recorded in the audit log. It is reported as its own error rather than as a wrong password, because the password may well have been right:
{
"errors": [{
"message": "too many failed sign-in attempts; try again in 8 seconds",
"path": ["login"],
"extensions": { "code": "THROTTLED", "retryAfterSeconds": 8 }
}]
}
If the service cannot reach the store that keeps these counts, it refuses to check passwords at all
rather than check them without counting. The login error then carries extensions.code set to
UNAVAILABLE. Treat it as an outage, not as a rejected credential. The OAuth token endpoint does not
use the store, so client authentication keeps working.
When the attempt store is full
The store has a fixed size, and every address that is tried takes a place in it for 10 minutes, whether or not an account exists for it. Someone sending sign-ins for enough different addresses can fill it.
When it is full, sign-in keeps working. Passwords are still checked and answered normally, but new failures are not counted, so addresses that are not already waiting are not slowed down until old entries expire. An address that is already waiting stays waiting, but only until that wait ends, which is at most 5 minutes. After that its failures are not counted either, so an account that is being attacked while the store is full is not protected by the backoff.
This is deliberate. Refusing every sign-in instead would let anyone who can fill the store lock every user out of the instance. Guessing is still limited by the cap on fields per request and by the cost of each password check.
Each attempt checked this way is counted by devicechain_usermanagement_credential_checks_total
with outcome="store_full". When the chart's alerting rules are enabled, the
CredentialAttemptStoreFull alert fires when there are any. The user-management log also carries a
warning at most once a minute while it lasts.
When the alert fires, someone is most likely trying many addresses:
- Find where the sign-in traffic comes from and block it upstream.
- If the traffic is legitimate, raise
instance.config.infrastructure.nats.kvStateMaxBytes. That size applies to every state bucket, so make sure the JetStream volume has room for the increase.
Detailed, per-type reference pages will be generated from the schemas as they stabilize.