Your first device, end to end
By the end of this page a device you created will have sent a reading, and you will be
looking at that reading in the console. No hardware, no firmware — the "device" is a curl
command, which is all a device is from the platform's side.
Budget about half an hour, most of it waiting for the bootstrap.
dcctl, plus five tools on your PATH: docker, kubectl, helm,
kind, and OpenTofu (the tofu binary;
terraform also works). Every bootstrap runs a preflight first and stops if one of them is
missing, so a gap costs you the first ten seconds rather than ten minutes.
helm is on that list even though dcctl carries the chart inside itself and installs it
through Helm's Go library rather than the command — the preflight checks for the binary
regardless, so treat it as required. ko and cloud-provider-kind are only warnings: you need
ko solely to build images from source (--build).
You do not need a cluster in advance. dcctl bootstrap local looks for a kind cluster named
after the instance and offers to create one if there is none; --kube-context <name> points it
at a cluster you already run, which it will never create or delete. Kubernetes 1.29 or newer
either way — older is refused, because the database charts refuse it.
dcctl preflight local runs exactly these checks without bootstrapping anything, and the
bootstrap guide has the detail.
Commands below assume the instance is reachable at localhost over plain HTTP, which is what
the flags in step 1 produce.
1. Bring up an instance
dcctl bootstrap local devicechain --host localhost --no-tls
The instance id — devicechain here — is not decoration. It becomes the namespace, and it is
the first segment of every device topic and ingest path on this page. If you choose a
different one, substitute it throughout.
When the bootstrap finishes it prints the namespace, the console URL, and the superuser
credential. The default superuser is superuser@devicechain.local with the password
devicechain.
Open the console at http://localhost/ and sign in. It will be empty — there is no tenant
yet, and every device belongs to one.
2. Create a tenant
A tenant is instance-level administration, so rather than walk the admin API by hand, use the command that does the whole dance in one step:
dcctl sim create demo
That mints a tenant sim-demo, creates an identity demo@sim.devicechain.local scoped to it
with the tenant-admin role and no instance-wide power, and writes a handshake file at
~/.devicechain/sims/demo.json. Read your identity's generated password out of it:
cat ~/.devicechain/sims/demo.json
The simPassword field is the password for demo@sim.devicechain.local. You will use both in
the next step.
dcctl sim create is really the first half of the simulator workflow. We
are borrowing it because minting a tenant plus a scoped identity is exactly what you need, and
doing it by hand means three mutations on the instance admin API. Everything after this step
is the ordinary tenant API that any application uses.
3. Get a tenant token
Authentication is two calls. The first proves who you are; the second picks which tenant you are acting in, because one person can belong to several.
curl -s -X POST http://localhost/api/user-management/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation($e:String!,$p:String!){login(email:$e,password:$p){identityToken}}",
"variables":{"e":"demo@sim.devicechain.local","p":"<simPassword from step 2>"}}'
That returns an identityToken — it says who you are, and nothing about where you are acting.
Exchange it for a tenant-scoped accessToken:
curl -s -X POST http://localhost/api/user-management/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation($t:String!,$n:String!){selectTenant(identityToken:$t,tenant:$n){accessToken}}",
"variables":{"t":"<identityToken>","n":"sim-demo"}}'
Keep that accessToken. Every call from here carries it:
export DC_TOKEN='<accessToken>'
4. Create the device
Devices are typed, so a device type comes first. Everything is addressed by a token you choose — a stable, human-readable handle — rather than by a generated id.
curl -s -X POST http://localhost/api/device-management/graphql \
-H "Authorization: Bearer $DC_TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"mutation($r:DeviceTypeCreateRequest){createDeviceType(request:$r){token}}",
"variables":{"r":{"token":"temp-probe","name":"Temperature probe"}}}'
curl -s -X POST http://localhost/api/device-management/graphql \
-H "Authorization: Bearer $DC_TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"mutation($r:DeviceCreateRequest){createDevice(request:$r){token}}",
"variables":{"r":{"token":"sensor-001","deviceTypeToken":"temp-probe","name":"Bench sensor"}}}'
Now give it a credential. This is what the device presents to prove it is itself; the platform expects one by default.
curl -s -X POST http://localhost/api/device-management/graphql \
-H "Authorization: Bearer $DC_TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"mutation($r:DeviceCredentialCreateRequest!){createDeviceCredential(request:$r){token}}",
"variables":{"r":{"token":"sensor-001-cred","deviceToken":"sensor-001",
"credentialType":"ACCESS_TOKEN",
"credentialId":"5f989616-2a0d-4160-8ae1-da5fad2898b2",
"enabled":true}}}'
Pick your own credentialId — any unguessable string. For an ACCESS_TOKEN credential the
credentialId is the secret the device presents, so treat it like a password rather than
like a name.
Refresh the console's Devices list and sensor-001 is there, with no data yet.
5. Open a path to the ingest endpoint
Device traffic does not go through the same door as the API. The ingress publishes the console
and /api/…; the device-ingest listener is a separate port that a stock install does not
expose outside the cluster. Forward it:
kubectl -n devicechain port-forward svc/event-sources 8081:8081
Leave that running in its own terminal.
This is a property of the default install, not of your setup: making a fleet's ingest endpoint
publicly reachable is a decision an operator should make on purpose, so nothing makes it for
you. A real deployment exposes it deliberately; for one curl from your laptop, a port-forward
is the smaller thing to do.
6. Send a reading
This is the device.
curl -i -X POST http://localhost:8081/devicechain/sim-demo/events \
-H 'Content-Type: application/json' \
-d '{"device":"sensor-001",
"eventType":"Measurement",
"credentialType":"ACCESS_TOKEN",
"credentialId":"5f989616-2a0d-4160-8ae1-da5fad2898b2",
"payload":{"entries":[{"measurements":{"temperature":"21.5"}}]}}'
202 Accepted means the event was queued. Two things about that body are worth noticing now,
because they catch nearly everyone once:
- Every payload wraps its readings in
entries, even a single one. - Every numeric value is a JSON string.
"21.5", not21.5. A bare number is rejected.
The path is /{instanceId}/{tenant}/events — devicechain is the instance from step 1 and
sim-demo is the tenant from step 2. A 404 here means the instance id is wrong: the route
exists only under this instance's own id.
A wrong tenant does not 404, and that is worth knowing before you debug one. Any
well-formed tenant name is accepted with 202 whether or not a tenant of that name exists; the
event is dropped further downstream, and nothing in the response says so. If a 202 produces no
data, check the tenant name before anything else.
Send a few more with different values, so there is a line to look at rather than a point:
for t in 21.9 22.4 22.1 23.0; do
curl -s -o /dev/null -X POST http://localhost:8081/devicechain/sim-demo/events \
-H 'Content-Type: application/json' \
-d "{\"device\":\"sensor-001\",\"eventType\":\"Measurement\",
\"credentialType\":\"ACCESS_TOKEN\",
\"credentialId\":\"5f989616-2a0d-4160-8ae1-da5fad2898b2\",
\"payload\":{\"entries\":[{\"measurements\":{\"temperature\":\"$t\"}}]}}"
sleep 1
done
7. See your data
In the console, open http://localhost/devices/sensor-001. The device now shows as
Online, with temperature and its latest value. Nothing declared it online — presence here
is inferred from the fact that an event arrived, which is how it works for a device on HTTP.
Over the API, the same thing:
curl -s -X POST http://localhost/api/device-state/graphql \
-H "Authorization: Bearer $DC_TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"{latestMeasurements(deviceToken:\"sensor-001\"){name value unit occurredTime}}"}'
And the history rather than the last value:
curl -s -X POST http://localhost/api/event-management/graphql \
-H "Authorization: Bearer $DC_TOKEN" -H 'Content-Type: application/json' \
-d '{"query":"{measurementEvents(criteria:{pageNumber:1,pageSize:20,deviceToken:\"sensor-001\"}){results{name value occurredTime} pagination{totalRecords}}}"}'
That is a device, end to end: registered, credentialed, reporting, and queryable.
If something did not work
| What you see | Usually means |
|---|---|
404 from the ingest POST | The instance id in the path is wrong — it is devicechain unless you changed it. A wrong tenant does not produce this. |
Connection refused on :8081 | The port-forward in step 5 is not running. |
400 from the ingest POST | A bare number instead of a string, readings not wrapped in entries, or a tenant segment that is not a valid token. |
202, but nothing appears | Either the tenant does not exist — a well-formed name is accepted whether or not it names anything — or the credential did not match. credentialId in the body must be exactly the one you created in step 4. |
429 from the ingest POST | The tenant is over its ingest rate limit — you are sending faster than its tier allows. |
503 from the ingest POST | The event could not be handed to the stream, and it was not stored. This is the one status to retry; the others are terminal for that request. |
| Unauthorized on an API call | The access token has expired, or you are sending the identityToken from the first call in step 3 instead of the accessToken from the second. |
Where to go next
-
One device is not a fleet.
dcctl sim createfrom step 2 also set up a simulated scenario. Build and run the simulator to have it provision a populated tenant and emit continuously:cd backend/sims/dc-simulator && make build./build/dc-simulator --handshake ~/.devicechain/sims/demo.jsonThen
dcctl sim status demo,dcctl sim stop demo,dcctl sim start demo. Note that the simulator reaches the same ingest endpoint, so it needs the port-forward from step 5 too. -
Connecting a device — the real transport, MQTT, with the credential on the connection as well as the event, plus every payload shape and the rules the pipeline enforces.
-
Transport capability matrix — what each transport supports in each direction before you commit to one.
-
Sending a command — the other direction.
-
Event processing — turning those readings into alarms.
Cleaning up
dcctl sim destroy demo
dcctl destroy local devicechain