Self-Hosted Node Telemetry via PUT /cloud

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 · Updated 2026-10-14 · For developers →

With a telemetry endpoint and token configured, a network-enabled node uploads a scheduled heartbeat — uptime, signal strength, free memory — as NDJSON. The endpoint is configurable through the node's LAN API, covered in the receipt printer API guide. Point PUT /cloud at your own receiver to collect those uploads. This page describes the firmware's receiver contract: record format, schedule, bounded retries and TLS limits.

Self-hosting changes the destination of scheduled heartbeats. POST /print and GET /status remain local API operations; configuring /cloud provides no remote API access or NAT traversal. Cloud traffic and peripheral work share device resources, so concurrent performance and recovery need qualification on the actual board and image.

This is a developer/ISV page. Hardware setup starts at the restaurants hub; receiver implementation starts here, with the developers hub covering the other local interfaces.

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 an admin-class LAN API write. The default apiMode: open allows it without an API key; admin and strict require X-PN-Api-Key. Check GET /config and obtain an existing key from your installer before changing the endpoint. See API authentication. The example below assumes open mode. The body is a strict JSON object with two independently optional fields:

{ "url": "https://telemetry.example.com/api", "token": "pn-7f3a.a1b2c3" }
FieldRule
url8–95 characters, must start with http:// or https://, must not end in /, no space, tab, CR, or LF anywhere in it
tokenup 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-pn-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:

StatuserrorWhen
400invalid_jsonbody missing, empty, oversized, or not a JSON object
401api_lockedthe current API mode requires a valid X-PN-Api-Key
409custody_holdstorage recovery requires physical owner confirmation before cloud reassignment
422invalid_configunknown field, wrong field type, url/token too long, url too short, or url fails a shape rule — detail says which
500persist_failedthe 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,"fw":"1.2.55","slot":"ota_0"}
{"type":"heartbeat","uptimeSeconds":86473,"freeHeap":142912,"fw":"1.2.55","slot":"ota_0"}

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 in-memory ring that feeds each flush holds at most 32 heartbeats — past that, the oldest record is dropped to make room, never the newest. Unsent records are lost on reboot or power loss; the buffer is not written to flash. 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; each line is serialized against a 448-byte budget, so a full ring stays under about 14 KB — in practice a batch is far smaller, since most heartbeat lines are a fraction of that budget. 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,"fw":"1.2.55","slot":"ota_0"}

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.

fw and slot say which firmware image the node is actually running — the version string and the app partition it booted from (factory, ota_0, or ota_1). They are what makes an over-the-air update verifiable from your own receiver: after a rollout, a node that came back on the new version reports it here, and one that rolled itself back reports the old one, without you having to ask the node anything.

These two fields are additive, and your existing receiver stays correct. They arrived in firmware 1.0.17, alongside over-the-air updates — the pair exists because a rollout is judged by which image a node is actually running. A node on anything older sends the line without them, so treat both as optional and don't key anything on their presence. If you validate incoming records against a schema, make them optional fields rather than required ones — a receiver that rejects heartbeats without fw will reject every record from a node it hasn't updated yet, and since a rejected batch is retried rather than dropped, that node's telemetry stops until you either update it or relax the check.

lan — the node's own address on your network. Newer firmware adds an optional object naming where the node is:

{"type":"heartbeat","uptimeSeconds":86413,"fw":"1.2.55","slot":"ota_0","lan":{"ip":"192.168.86.34","host":"proxynodes-pn-d938"}}

ip is the address of whichever transport is currently carrying the node — the wire on a cabled node, not the standby radio. host is its mDNS name without the .local suffix; append that yourself to build a URL. httpPort rides along only when the node is not on the default port 80.

The whole object is omitted when the node has no address to report — between DHCP leases, or with nothing carrying it. It is never sent as 0.0.0.0, so its absence means "unknown" and nothing else.

This is a private address, and it is worth knowing you are storing it. It is an RFC1918 address on your own LAN, useful only to something already on that network, and it exists so a fleet view can hand someone somewhere to go instead of a hunt through a router's DHCP table. It is still one more thing in your database: if you would rather not keep it, drop the key on ingest — nothing else in the record depends on it. The node's server-side opt-out suppresses the whole heartbeat, this field included.

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 responseWhat the node does
any 2xxclears the whole batch from its buffer, counts it in sentBatches
401shown as "token rejected" on the node's own page — buffer is kept, retried next interval
3xxnot 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 allbacklog kept, retried at the next interval

Retries can duplicate records — the batch id can't dedupe

Retries do not guarantee delivery of every heartbeat: the bounded RAM buffer can lose records on overflow, reboot, or power loss.

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 by PUT /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.

The certificate-authority description above applies to the ESP32-S3 Raw sold today. The Pico 2 W development line uses a separate TLS implementation. Its bench checks have demonstrated public-server connections and rejection of a wrong hostname and an untrusted self-signed certificate. Those checks do not establish full certificate validation: the current Pico implementation has no trusted wall clock for certificate validity dates, and certificate revocation has not been qualified. Do not treat the development image as a production security qualification.

Isolated Pico staging checks delivered and persisted heartbeats and fetched recipes, but those bounded smoke tests do not qualify sustained concurrent cloud and printing. Separate LAN monitors still observed intermittent new-connection timeouts while an existing HTTP connection continued responding; the cause remains unresolved. These observations do not establish that cloud traffic caused the timeouts. The Pico line remains a prototype and is not on sale.

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

For software-only receiver development, request simulator evaluation access to the developer workspace first; pnpm sim is not a public package install. The twin supports PUT/GET /cloud and posts modeled telemetry to /v1/ingest in NDJSON batches. Point it only at your test receiver with a test token. This verifies receiver handling, not physical sensor values, device persistence or every outage condition. The CI guide covers process setup and simulation limits.

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 Proxy Node Raw board 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?
It is an admin-class write: the default open mode allows it without a key, while admin and strict require X-PN-Api-Key. Check GET /config and obtain an existing key from your installer. Keep the node on a restricted LAN; the API key does not encrypt its HTTP traffic.
What happens if my server is down when a heartbeat is due?
The node retries on its normal interval (60 s by default), buffering up to 32 unsent heartbeats in memory. The oldest record is evicted when full; reboot or power loss discards unsent records. A successful 2xx response clears the submitted batch. Recovery is checked at the next scheduled attempt, not immediately when the server returns.
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, fw and slot, plus rssi when the node has Wi-Fi signal to report, and a lan object naming the node's own LAN address and mDNS hostname when it has one. It's the same JSON line verbatim, batched as newline-delimited JSON.
Does the heartbeat tell you where the node is on my network?
Yes, in the optional lan object — the IP of whichever transport is carrying it, and its mDNS hostname without the .local suffix. It's an RFC1918 address on your own LAN, and it exists so a fleet view can offer somewhere to go rather than making someone read the router's DHCP table. Drop the key on ingest if you'd rather not store it; nothing else in the record depends on it.
Do I need to change my receiver for the new fw and slot fields?
No. They were added in firmware 1.0.17 and are purely additive — an existing receiver keeps working unchanged, and a node on older firmware simply doesn't send them. The one thing to check is that you aren't validating heartbeats against a schema that requires every field it knows about: treat fw and slot as optional, or a strict receiver will reject records from nodes it hasn't updated yet.
How do I tell whether a firmware update actually landed?
Watch fw and slot across heartbeats. A node that took an update comes back reporting the new version from the other app slot (factory -> ota_0, or ota_0 -> ota_1), with uptimeSeconds restarted from near zero. A node that took a bad update rolls itself back and comes up reporting the previous version again — so the same two fields tell you it failed, without you having to reach the node.

Related reading