Store and Forward

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.

Nothing to enable on the platform. Every HTTP write accepts a timestamp, and over MQTT the JSON Splitter can read one from the message. This page is mostly about the part that lives in your firmware: buffering while offline, and replaying afterwards.

1Why it matters

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.

This is different from knowing that the board dropped. Connection state tells you the board went away. Store and forward is what stops the measurements from disappearing with it. They solve different halves of the same problem — see Device Online/Offline Detection for the other half.

2Over HTTP: the time field

Every 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.

FieldMeaning
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 /.
One timestamp per request. The 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.
Send text, not a Unix number. A number is read as milliseconds since 1970. A board that sends seconds — 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.

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.

3Over MQTT: a timestamp in the JSON

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"}
  1. Create a standard (non-historical) field garden/log for the whole message, plus the historical fields garden/temperature and garden/humidity for the values.
  2. In a dashboard open Tools → JSON Splitter, choose garden/log as the source, enter ts as the time path, and add one row per value: temperature → garden/temperature, humidity → garden/humidity.
  3. Publish every reading this way — live ones with the current time, buffered ones with the time they were taken. Each value is stored at the time in ts.
The same rules as for HTTP apply: ISO text in UTC, and a missing or unreadable 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.

4The firmware side

The board needs three things: somewhere to keep readings, a clock it can trust, and a replay loop for when the link returns.

Where to keep them

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.

Getting the time right

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.

Never send a guessed timestamp. If the clock is not set, send the reading without 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.

Replaying

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.

Pace the replay. Each API key may send 600 write requests per minute. Going over returns 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.
Create the fields before you rely on the replay. A write to a field that does not exist still answers 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.

Complete example — ESP32

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();
  }
}

5Things to know before you rely on it

The field must be historical

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.

Replayed readings trigger automations

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.

Old data is still subject to retention

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.