Self-Hosted Node Telemetry: the PUT /cloud Contract
The wire contract for pointing a node at your own telemetry receiver: PUT /cloud validation, the NDJSON batch format, retry rules, and TLS limits.
Published 2026-08-03 · For developers →
Every node phones home on a schedule: a small heartbeat — uptime, signal strength, free memory — uploaded as NDJSON over HTTPS. That endpoint is configurable per node over the node's own LAN API (the same one the receipt printer API guide covers end to end), and the target doesn't have to be the Proxy Nodes service. Point PUT /cloud at a server you run, and your node uploads to it instead. This page is that receiver's wire contract, verified against the firmware that sends it: what the node sends, when, how it retries, and the roughly fifteen lines it takes to accept a batch.
One thing this is not, up front: telemetry upload is not remote access to the node, not a path for print jobs, and not NAT traversal. POST /print, GET /status, and everything else that drives a peripheral stays entirely on the LAN, with or without a cloud configured — nothing about /cloud changes that. Self-hosting the receiver only changes where a heartbeat lands.
Most restaurant operators never need this page at all: a node already phones home once a token is provisioned, and printing behaves identically whether uploads are on, off, or repointed elsewhere. This is a developer/ISV page — setting up hardware rather than writing a receiver starts at the restaurants hub; building the receiver itself starts here, and at the developers hub for everything else a node exposes.
What a node uploads, and why it's opt-in
A node's ingest task appends one heartbeat to a small in-memory ring on a fixed interval and attempts to flush the whole ring in a batch. Nothing is buffered — let alone sent — until an operator sets both a url and a token; an unconfigured node retains no history at all, by design. Once configured, the node uploads on its own schedule regardless of whether anyone is watching.
That's the entire feature surface: one record type, one endpoint, one retry policy. The rest of this page is that contract in detail.
Point the node at your server: PUT /cloud
PUT /cloud is a LAN endpoint like the rest of the node's API — no authentication beyond being on the network the node is on. The body is a strict JSON object with two independently optional fields:
{ "url": "https://telemetry.example.com/api", "token": "pn-7f3a.a1b2c3" }| Field | Rule |
|---|---|
url | 8–95 characters, must start with http:// or https://, must not end in /, no space, tab, CR, or LF anywhere in it |
token | up to 95 characters; an empty string clears the stored token and stops uploads |
Either field can be sent alone — moving the url without touching the token, or vice versa. An unknown key (a typo like "tokn") is a hard failure rather than a silent no-op:
curl -X PUT http://proxynodes-7f3a.local/cloud \
-H "content-type: application/json" \
-d '{"url":"https://telemetry.example.com/api","token":"pn-7f3a.a1b2c3"}'A successful PUT answers 200 with the same CloudStatus shape GET /cloud returns (below). Failure modes:
| Status | error | When |
|---|---|---|
| 400 | invalid_json | body missing, empty, oversized, or not a JSON object |
| 422 | invalid_config | unknown field, wrong field type, url/token too long, url too short, or url fails a shape rule — detail says which |
| 500 | persist_failed | the node couldn't write the change to flash; detail carries the underlying error name |
The rule that exists for your token's safety
Repointing the url in a request that says nothing about the token clears the stored token in the same save. This is deliberate, not a bug: a naive client that fetches the current config, edits only the url, and PUTs the whole object back — or an attacker who can reach the LAN endpoint — must never leave a working bearer token attached to a host that wasn't handed it on purpose. The node's TLS trust is a public certificate-authority bundle, which is no defense here: a malicious url presents a perfectly valid certificate for itself.
The trigger is the url actually changing — a re-send of the identical url never touches the token — and including token explicitly in the request, even as "", always wins over this guard, because it states what you mean the token to be. Practically: if you're only rotating a token, you can omit url; if you're moving hosts, paste the new token in the same request, or expect it to be gone.
Check what's configured: GET /cloud
{
"url": "https://telemetry.example.com/api",
"tokenSet": true,
"enabled": true,
"buffered": 0,
"sentBatches": 12,
"lastStatus": 202
}The token itself is never in this response, or any response — it's write-only on the wire, the same way a Wi-Fi password is on POST /wifi. tokenSet is the only signal you get that one exists. enabled is true only when both a url and a token are stored; lastStatus is null until the first upload attempt, then the raw HTTP status of the most recent one (or -1 if the node reached nothing at all — DNS, TCP, and TLS failures all collapse to that one sentinel).
What arrives at your server
Every attempt is:
POST <your-url>/v1/ingest HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/x-ndjson
X-PN-Batch: 9f86d081884c7d65
{"type":"heartbeat","uptimeSeconds":86413,"rssi":-52,"freeHeap":142880}
{"type":"heartbeat","uptimeSeconds":86473,"freeHeap":142912}
The node appends /v1/ingest to whatever url you configured — that's the entire routing contract, so a receiver just needs one POST handler at that path. X-PN-Batch is 16 lowercase hex characters. The token is opaque to the node: it never parses or validates the string beyond the 95-character cap, so your server can issue whatever token format it wants.
One token per node
Nothing else on the wire says which node sent a request — no device-id field in the heartbeat body, no identifying header beyond Authorization. The bearer token is the only per-node discriminator you get, so mint one token per node and identify the sender by which token a request presents, not by anything in the payload. That's exactly how the Proxy Nodes service's own receiver does it: a device token is <deviceId>.<secret>, and the id half is recovered by splitting the bearer on its first . before verifying the secret against a stored per-device hash — a self-hosted receiver's token format is entirely its own choice, since the node never parses it either way.
Batch size ceiling. The ring that feeds each flush holds at most 32 heartbeats — past that, the oldest record is dropped to make room, never the newest. Whatever's currently in the ring is what goes out on the next scheduled flush, so one attempt can carry anywhere from 1 to 32 records; the serialization buffer that holds them is capped at 3,072 bytes (32 records × a 96-byte per-line budget) — in practice a batch is far smaller, since a heartbeat line is usually under 60 bytes. There's no larger-batch mode to plan around.
The record. A heartbeat is currently the only record type this endpoint ever sends:
{"type":"heartbeat","uptimeSeconds":86413,"rssi":-52,"freeHeap":142880}rssi is present only when the node's Wi-Fi radio has an active association to report a signal strength for — omitted entirely (not null) otherwise, as in the second line of the example above. uptimeSeconds and freeHeap are always present.
Cadence and what each response means
The node appends a heartbeat and attempts a flush every 60 seconds by default (the shipped Kconfig interval), independent of whether the previous attempt succeeded — a cloud that's down doesn't change the cadence, only how much backlog rides along on the next try. That backlog is capped at 32 heartbeats (roughly half an hour at the default interval); once full, the oldest record is dropped to make room for the newest.
| Your response | What the node does |
|---|---|
| any 2xx | clears the whole batch from its buffer, counts it in sentBatches |
| 401 | shown as "token rejected" on the node's own page — buffer is kept, retried next interval |
| 3xx | not followed. Redirects are disabled on purpose, so a token can never get replayed to a different host by a 3xx pointing somewhere else. Treated the same as any other non-2xx: backlog kept, retried. If you're behind a reverse proxy, terminate redirects there — the node will never chase one. |
| anything else, or no response at all | backlog kept, retried at the next interval |
At-least-once delivery — and the batch id can't dedupe
X-PN-Batch is regenerated on every attempt — it identifies the attempt, not the payload, so it's useless for spotting a redelivered batch. A retry isn't even a byte-for-byte repeat of the failed attempt, either: because the node appends a new heartbeat and re-flushes the whole ring every interval regardless of whether the last flush landed, a retry carries every record still unacknowledged plus whatever heartbeat the next interval added (or, once the ring hits its 32-record cap, drops the oldest to make room instead) — a superset, not a copy.
If your server needs to guard against processing the same heartbeat twice — after a timeout where the node's request actually landed but your 2xx never made it back, for instance — there's no per-record id to dedupe on, and no device-id field either (see "one token per node," above). The practical option is deduping per-token on uptimeSeconds: it's monotonic within one boot session, so the pair (token, uptimeSeconds) is unique for as long as that node stays up. Otherwise, accepting an occasional duplicate insert is simpler than building a table for it.
TLS: why a self-signed certificate won't work
The node verifies your server's certificate against ESP-IDF's bundled public certificate-authority list, and there is no way to install a private CA on it. A self-signed certificate will fail the handshake every time — this isn't a bug to work around, it's a hard limit of what firmware on this device can do. Two options that actually work:
- Plain
http://on the LAN. Legal and explicitly allowed byPUT /cloud's validation. Understand what it costs: the token and every heartbeat cross that network unencrypted, which the node's own Cloud card warns about on its page. - Terminate with a publicly-trusted certificate — a reverse proxy with a Let's Encrypt (or similar) cert in front of your receiver, same as you'd front any other service.
A minimal receiver
The whole contract in code — bearer check, split on newlines, accept. This is plain HTTP (front it with a TLS-terminating proxy for a real deployment — see the TLS section above):
import http from "node:http";
const TOKEN = "pn-7f3a.a1b2c3";
http.createServer((req, res) => {
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
res.writeHead(401).end();
return;
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
for (const line of Buffer.concat(chunks).toString("utf8").split("\n")) {
if (line) console.log(JSON.parse(line));
}
res.writeHead(202).end();
});
}).listen(8080);That's a working receiver: any 2xx (202 here) tells the node the batch landed and its buffer is cleared. Everything past this — durable storage, per-node tokens, dedupe, alerting on a gap in heartbeats — is the part you're building the server for in the first place.
Testing without hardware
You don't need a node on your bench to build against this contract. The free digital twin (pnpm sim) implements the same PUT/GET /cloud endpoints and posts to /v1/ingest in the identical NDJSON format, so you can point it at a receiver under development and watch real batches arrive before any firmware does. The service-in-CI, fault-injection patterns that make that practical for printing are covered in testing receipt printing in CI — the ingest side follows the same shape even though that guide's examples are about print jobs.
If you'd rather not run a receiver at all, a node ships pointed at the Proxy Nodes service by default — but that's only the url; uploads stay off until an operator also sets a token, exactly as described above, self-hosted or not. Self-hosting is for teams who want their own analytics pipeline, or who want telemetry to never leave infrastructure they control. Either way, printing itself never depends on this: a Station Hub prints, kicks the drawer, and answers status queries with /cloud cleared, disabled, or pointed at a server that's been down for a week.
Frequently asked questions
- Does pointing a node at my own server give it remote access to the node?
- No. PUT /cloud only changes where periodic heartbeats are uploaded to. Printing, status, and every other node endpoint stay LAN-only, with no cloud in that path at all — self-hosting telemetry doesn't open any door into the node from outside.
- Is PUT /cloud authenticated?
- No — it's a LAN endpoint with the same trust model as the rest of the node's API: whoever can reach the node's IP can configure it. Restrict who's on that network the same way you would for any other node endpoint.
- What happens if my server is down when a heartbeat is due?
- The node keeps trying on its normal interval (60 s by default) and keeps the unsent heartbeats in a 32-record ring, oldest dropped first if it fills. Nothing is lost until the ring overflows, and delivery resumes automatically the moment your server answers with a 2xx.
- Can I use a self-signed certificate on my receiver?
- No. The node validates TLS against ESP-IDF's public certificate-authority bundle only, with no way to add a private CA. Use plain http:// on the LAN (accepting that the token and telemetry travel unencrypted) or terminate with a publicly-trusted certificate.
- How do I tell which node sent a batch, and avoid double-counting a retry?
- Nothing in the heartbeat body or the request headers carries a device id — the bearer token is the only per-node signal on the wire, so mint one token per node and identify the sender by which token it presents (the Proxy Nodes service does exactly this, splitting a <deviceId>.<secret> token before verifying it). For retries: X-PN-Batch is regenerated per attempt, not per payload, so it can't detect a redelivered batch either — dedupe per-token on uptimeSeconds if an occasional duplicate would matter to you, or just accept it.
- What record types does the ingest endpoint send today?
- Just one: the heartbeat — type, uptimeSeconds, freeHeap, and rssi when the node has Wi-Fi signal to report. It's the same JSON line verbatim, batched as newline-delimited JSON.
Related reading
- Receipt Printer API: the Complete GuideEvery way software prints receipts in 2026 — ESC/POS, port 9100, vendor SDKs, cloud relays — and how a local HTTP API on the LAN compares.
- Port 9100 Printing: Raw TCP for POS, ExplainedHow raw port 9100 printing works, why every POS uses it, how to test it with netcat, and its blind spots — status, discovery, and error handling.
- Test Receipt Printing in CI — No HardwareHow to put receipt printing under CI: render ESC/POS to text, assert on bytes, simulate paper-out and offline faults, and run conformance checks.
- ESC/POS Printer Status: DLE EOT ExplainedThe DLE EOT real-time status commands byte by byte — paper, cover, drawer, errors — plus why many printers lie or stay silent, and what to do about it.