A board that loses its connection does not have to lose its measurements. Keep them on the board while the link is down, then send them afterwards with the times they were actually taken. The history fills in as if nothing had happened.
Without buffering, an outage leaves a hole. Worse, the hole is invisible: the chart simply has no points between two timestamps, and nothing on the page says whether the sensor was quiet, the board was off, or the network was down.
With buffering, the same outage costs nothing. The readings arrive late but land in the right place, and the record is continuous — which matters when the data is not just a dashboard but a quality record, a billing input, or the evidence for what a machine did last Tuesday.
time fieldEvery write to POST /api/data/write may carry an optional time. When it
is present and valid, the reading is stored at that time. When it is absent, the reading is
stored at the moment it arrived. A board that has been buffering simply sends its backlog one
request at a time, each with the timestamp it recorded.
| Field | Meaning |
|---|---|
time |
When the reading was taken, as ISO 8601 text in UTC — e.g.
2026-09-07T14:32:05Z. An unreadable value is not an error: the reading
is stored with the arrival time instead, silently. |
field + value |
One reading. field is the field's full name, e.g. garden/temperature. |
data |
An array of { field, value } — several fields at the same
timestamp. |
path |
Optional. A common prefix joined to every field name with /. |
time applies to the whole request. The data array sends many
fields at one moment — it does not send many moments. To replay ten readings you send
ten requests, each with its own time.
1757255525 — puts its readings in January 1970, where they are removed by the next
retention pass. Format the time as ISO text, as in the examples.
A live reading, and the same reading from the buffer:
POST https://api.virtuino.com/api/data/write
x-api-key: YOUR_API_KEY
{
"field": "garden/temperature",
"value": 21.4
}
POST https://api.virtuino.com/api/data/write
x-api-key: YOUR_API_KEY
{
"field": "garden/temperature",
"value": 21.4,
"time": "2026-09-07T14:32:05Z"
}
Several fields recorded at the same instant travel together:
{
"path": "garden",
"time": "2026-09-07T14:32:05Z",
"data": [
{ "field": "temperature", "value": 21.4 },
{ "field": "humidity", "value": 63 }
]
}
With the VirtuinoCloud library the timestamp is the
last argument of write():
cloud.write("garden/temperature", 21.4, false, "2026-09-07T14:32:05Z").
Full API reference: HTTP API documentation.
A plain MQTT value — 21.4 on garden/temperature — carries no time and
is stored when it arrives. To replay a backlog over MQTT, put the time inside a JSON
message and let a JSON Splitter take it out:
Topic: garden/log
Payload: {"temperature": 21.4, "humidity": 63, "ts": "2026-09-07T14:32:05Z"}
garden/log for the
whole message, plus the historical fields garden/temperature and
garden/humidity for the values.garden/log as
the source, enter ts as the time path, and add one row per value:
temperature → garden/temperature, humidity →
garden/humidity.ts.ts
means the arrival time. The garden/log field itself only keeps the last message —
the history lives in the value fields.
The Virtuino IoT app is a separate product. It has its own local storage and its own way of carrying the timestamp in MQTT. If you are building against the app, follow Store and Forward for the Virtuino IoT app instead — the settings do not transfer.
The board needs three things: somewhere to keep readings, a clock it can trust, and a replay loop for when the link returns.
On an ESP32, a file in LittleFS is usually enough — one line per reading, timestamp first. A file survives a reset, which RAM does not, and a reset is exactly what tends to happen during a power problem. Decide a maximum size, so that a long outage can never fill the flash.
Buffering is only useful if the board knows what time it is. Sync with NTP while the network is up; the ESP32 keeps counting while the network is down. After a reset without network, however, the clock is gone until NTP answers again.
time and accept that it lands
at arrival time — or discard it. A history that looks continuous but is wrong cannot be
detected later by anyone.
When the connection returns, send the oldest reading first and remove it from the buffer only after the server has accepted it. If a request fails, stop and try again later — do not skip ahead, or you create a gap inside the replay itself.
429 with a Retry-After header. A short pause between requests — 150 ms
in the example — keeps a long backlog well inside the limit. Treat 429 like any
other failure: wait, and resume from the same reading.
200, with the name listed in
skipped_fields. The VirtuinoCloud library (1.1.1 or later) recognises this:
write() returns false and lastStatus() is 404, so the
example keeps the reading. Code that talks to the API directly must check that count
equals total in the answer. Rejected writes also appear in the Debug Monitor.
One reading per minute. Online, it is sent at once with its timestamp; offline — or if the send
fails — it is appended to /buffer.txt. Every 30 seconds while online, the backlog is
replayed oldest first. Needs the VirtuinoCloud library
and a historical field garden/temperature.
// Store and forward — ESP32 + VirtuinoCloud library
// One reading per minute. Sent at once when online, kept in flash when not,
// and replayed later with the time it was actually taken.
#include <WiFi.h>
#include <LittleFS.h>
#include <time.h>
#include <VirtuinoCloud.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
VirtuinoCloud cloud("YOUR_API_KEY"); // Console → Keys & Sub Users
const char* FIELD = "garden/temperature"; // must be a HISTORICAL field
const char* BUFFER = "/buffer.txt";
const char* REST = "/rest.txt";
const size_t MAX_BYTES = 200000; // about 7,000 readings
const unsigned long READ_EVERY_MS = 60000; // one reading per minute
const unsigned long REPLAY_EVERY_MS = 30000; // try the backlog every 30 s
float readSensor() {
return 20.0 + random(0, 50) / 10.0; // replace with your sensor
}
// NTP has answered once the clock is past 2023.
bool clockIsSet() {
return time(nullptr) > 1700000000;
}
// Current time in UTC, e.g. "2026-09-26T14:32:05Z"
void utcNow(char* out, size_t size) {
time_t now = time(nullptr);
strftime(out, size, "%Y-%m-%dT%H:%M:%SZ", gmtime(&now));
}
// Append one line "timestamp value" to the buffer file.
void keep(const char* ts, float value) {
File f = LittleFS.open(BUFFER, FILE_APPEND);
if (!f) return;
if (f.size() < MAX_BYTES) f.printf("%s %.2f\n", ts, value); // full: stop keeping
f.close();
}
// Send the backlog oldest first. Stop at the first failure and keep that
// reading and everything after it, in order, for the next attempt.
void replay() {
if (!LittleFS.exists(BUFFER)) return;
File in = LittleFS.open(BUFFER, FILE_READ);
File rest = LittleFS.open(REST, FILE_WRITE);
bool sending = true;
int sent = 0;
while (in.available()) {
String line = in.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
if (sending) {
int space = line.indexOf(' ');
String ts = line.substring(0, space);
float value = line.substring(space + 1).toFloat();
if (cloud.write(FIELD, value, false, ts.c_str())) {
sent++;
delay(150); // stay well under 600 requests/minute
continue;
}
sending = false; // failed: keep the rest
}
rest.println(line);
}
in.close();
rest.close();
File check = LittleFS.open(REST, FILE_READ); // what is left to send
size_t left = check.size();
check.close();
LittleFS.remove(BUFFER);
if (left > 0) LittleFS.rename(REST, BUFFER);
else LittleFS.remove(REST);
Serial.printf("Replayed %d readings, %s\n", sent, left ? "some left" : "backlog empty");
}
void setup() {
Serial.begin(115200);
LittleFS.begin(true); // formats the partition on first use
WiFi.begin(WIFI_SSID, WIFI_PASS); // reconnects by itself after a dropout
configTime(0, 0, "pool.ntp.org"); // UTC; the clock keeps running offline
cloud.setClientId("garden-logger");
}
void loop() {
static unsigned long lastRead = 0, lastReplay = 0;
bool online = (WiFi.status() == WL_CONNECTED);
if (millis() - lastRead >= READ_EVERY_MS) {
lastRead = millis();
float value = readSensor();
if (!clockIsSet()) {
// No clock, no timestamp: send it live or drop it — never guess a time.
if (online) cloud.write(FIELD, value);
} else {
char ts[25];
utcNow(ts, sizeof(ts));
if (!(online && cloud.write(FIELD, value, false, ts))) keep(ts, value);
}
}
if (online && millis() - lastReplay >= REPLAY_EVERY_MS) {
lastReplay = millis();
replay();
}
}
MAX_BYTES) new
readings are not kept until the backlog is sent.A field that is not historical keeps only its latest value — each write replaces the previous one. Replaying a backlog into such a field does not build a history; it leaves you with whichever reading was sent last, which during a replay is an old one. Open the field in Console → Fields and make sure it is historical before you buffer anything for it.
A replayed reading is processed like a live one: it can trigger rules, scripts and HTTP Requests. A rule may therefore send an alert, when the backlog arrives, about a temperature measured hours ago. A script that converts the value should keep the reading's own time, or its result is stored at the moment of the replay:
outputs.celsius = {
value: Math.round(celsius * 10) / 10,
time: inputs.raw.time // the time of the reading, not of the script run
};
See Scripts — Keep the measurement time.
Stored readings are removed once they pass the retention period of your plan. Replaying hours or days of data is unaffected. Importing history older than your retention period is not useful: it is removed on the next clean-up.
For the full write API — authentication, every field, and the error responses — see the HTTP API documentation. For alerting when a board stops reporting at all, see Device Online/Offline Detection.