Script Recipe for Lift JSON (beta)
Use this script when you have readings in a JSON file, for example exported by your own system each hour, and want to send them to a Lift JSON connection. It needs Node.js 18 or later and nothing else to install.
Lift shows a copy of your logger's record. Use your logger's own record for decisions about your vaccines and medicines, and for inspections. Lift is not a validated system.
Sensor connections are in beta on every plan, at no extra charge during the beta. This is a recipe you copy and adapt. Send a test reading first and check the answer.
Before you start#
Add a Lift JSON (signed) connection in the customer portal under Devices & sensors > Sensors > Connections, and copy its web address and its secret. Lift shows the secret only once. See Lift JSON for the body, the signature and every answer Lift can give.
1. Save the script#
Save this as send-readings.cjs:
#!/usr/bin/env node
// OpenBoxes Lift: send readings from a JSON file to a Lift JSON sensor connection.
// A recipe to copy and adapt. Node.js 18 or later, nothing to install. Usage:
// LIFT_SENSOR_URL='https://api.openboxes.cloud/api/v1/sensors/ingest/<connection id>' \
// LIFT_SENSOR_SECRET_FILE="$HOME/.lift-sensor-secret" node send-readings.cjs readings.json
// LIFT_SENSOR_SECRET_FILE is a file holding only the secret, readable by you alone (chmod 600);
// the secret is trimmed. readings.json is a list of readings, or { "readings": [...] }; a
// reading is { "device", "at", "temp", "unit" } as the Lift JSON docs describe, and any other
// field is left out. The script sends at most 500 readings a request, signs each request when
// it sends it, waits and re-signs when Lift answers 429, and prints Lift's counts.
'use strict';
// lift-signer:begin
// Signs a Lift JSON body: X-Lift-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + body)>.
// Send exactly the `body` string you signed; re-formatting it after signing breaks the signature.
function liftUtf8(text) {
var out = [];
for (var i = 0; i < text.length; i++) {
var c = text.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
var d = text.charCodeAt(i + 1);
if (d >= 0xdc00 && d <= 0xdfff) {
c = 0x10000 + ((c - 0xd800) << 10) + (d - 0xdc00);
i++;
}
}
if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd;
if (c < 0x80) out.push(c);
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
}
return out;
}
var LIFT_SHA256_K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
function liftSha256(bytes) {
var h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
var m = bytes.slice();
var bitLength = bytes.length * 8;
m.push(0x80);
while (m.length % 64 !== 56) m.push(0);
for (var s = 7; s >= 0; s--) m.push(s >= 4 ? Math.floor(bitLength / Math.pow(2, 8 * s)) & 255 : (bitLength >>> (8 * s)) & 255);
var w = new Array(64);
for (var off = 0; off < m.length; off += 64) {
for (var t = 0; t < 16; t++) {
w[t] = ((m[off + 4 * t] << 24) | (m[off + 4 * t + 1] << 16) | (m[off + 4 * t + 2] << 8) | m[off + 4 * t + 3]) >>> 0;
}
for (t = 16; t < 64; t++) {
var x = w[t - 15], y = w[t - 2];
var s0 = ((x >>> 7) | (x << 25)) ^ ((x >>> 18) | (x << 14)) ^ (x >>> 3);
var s1 = ((y >>> 17) | (y << 15)) ^ ((y >>> 19) | (y << 13)) ^ (y >>> 10);
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
}
var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], k = h[7];
for (t = 0; t < 64; t++) {
var S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
var t1 = (k + S1 + ((e & f) ^ (~e & g)) + LIFT_SHA256_K[t] + w[t]) >>> 0;
var S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
var t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
k = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + k) >>> 0;
}
var out = [];
for (var i = 0; i < 8; i++) out.push((h[i] >>> 24) & 255, (h[i] >>> 16) & 255, (h[i] >>> 8) & 255, h[i] & 255);
return out;
}
function liftHmacSha256Hex(secret, message) {
var key = liftUtf8(secret);
if (key.length > 64) key = liftSha256(key);
var inner = [], outer = [];
for (var i = 0; i < 64; i++) {
var kb = i < key.length ? key[i] : 0;
inner.push(kb ^ 0x36);
outer.push(kb ^ 0x5c);
}
var mac = liftSha256(outer.concat(liftSha256(inner.concat(liftUtf8(message)))));
var hex = '';
for (i = 0; i < mac.length; i++) hex += (mac[i] < 16 ? '0' : '') + mac[i].toString(16);
return hex;
}
// t: Unix time in seconds (Lift accepts it within 5 minutes). Returns the X-Lift-Signature value.
// The secret is trimmed, so a space or line break copied with it does not break the signature.
function liftSignature(secret, t, body) {
return 't=' + t + ',v1=' + liftHmacSha256Hex(String(secret).trim(), t + '.' + body);
}
// The Lift JSON body for 1 to 500 readings, keeping only the documented fields of each reading:
// device, at, temp and unit (unit left out when absent). Throws on anything else.
function liftBody(readings) {
if (!Array.isArray(readings) || readings.length === 0 || readings.length > 500) {
throw new Error('Send 1 to 500 readings in one request, not ' + (Array.isArray(readings) ? readings.length : 0));
}
var out = [];
for (var i = 0; i < readings.length; i++) {
var r = readings[i];
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
throw new Error('Reading ' + (i + 1) + ' is not an object');
}
out.push({ device: r.device, at: r.at, temp: r.temp, unit: r.unit });
}
return JSON.stringify({ readings: out });
}
// lift-signer:end
const fs = require('node:fs');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function readSecret(file) {
const mode = fs.statSync(file).mode;
if (process.platform !== 'win32' && (mode & 0o077) !== 0) {
console.error('warning: ' + file + ' can be read by other users; run chmod 600 on it');
}
return fs.readFileSync(file, 'utf8').trim();
}
async function sendBatch(url, secret, readings) {
const body = liftBody(readings);
for (let attempt = 1; attempt <= 4; attempt++) {
const t = Math.floor(Date.now() / 1000);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Lift-Signature': liftSignature(secret, t, body) },
body: body,
});
const text = await res.text();
if (res.status === 200) return JSON.parse(text);
if (res.status === 429 && attempt < 4) {
const wait = Math.max(1, Math.min(300, parseInt(res.headers.get('retry-after'), 10) || 5));
await sleep(wait * 1000 + 100);
continue;
}
throw new Error('Lift answered ' + res.status + (text ? ': ' + text.slice(0, 200) : ''));
}
throw new Error('Lift was still busy after 4 tries');
}
async function main() {
const url = (process.env.LIFT_SENSOR_URL || '').trim();
const secretFile = process.env.LIFT_SENSOR_SECRET_FILE;
const file = process.argv[2];
if (!url || !secretFile || !file) {
console.error('usage: LIFT_SENSOR_URL=... LIFT_SENSOR_SECRET_FILE=... node send-readings.cjs readings.json');
process.exit(2);
}
const secret = readSecret(secretFile);
if (!secret) {
console.error('the secret file ' + secretFile + ' is empty');
process.exit(2);
}
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
const readings = Array.isArray(data) ? data : (data && Array.isArray(data.readings) ? data.readings : []);
if (readings.length === 0) {
console.error('no readings in ' + file);
process.exit(2);
}
for (let i = 0; i < readings.length; i += 500) {
const counts = await sendBatch(url, secret, readings.slice(i, i + 500));
console.log(JSON.stringify(counts));
}
}
main().catch(err => {
console.error(err.message);
process.exit(1);
});
2. Keep the secret in a protected file#
Create a file such as ~/.lift-sensor-secret with a text editor, paste only the secret into it and save it. Then make it readable by you alone:
chmod 600 ~/.lift-sensor-secret
The script reads the secret from this file and trims any space or line break around it. It warns you if other users can read the file. Keeping the secret in a file, rather than typing it into the shell, keeps it out of your shell history.
3. Make a readings file#
A list of readings, or an object with a readings list, exactly as on the Lift JSON page. The script sends only device, at, temp and unit from each reading and leaves any other field out.
[
{ "device": "fridge-1", "at": "2026-10-08T12:00:00Z", "temp": 4.5 },
{ "device": "fridge-1", "at": "2026-10-08T12:15:00Z", "temp": 4.7 }
]
4. Run it#
export LIFT_SENSOR_URL='https://api.openboxes.cloud/api/v1/sensors/ingest/<connection id>'
export LIFT_SENSOR_SECRET_FILE="$HOME/.lift-sensor-secret"
node send-readings.cjs readings.json
The script sends up to 500 readings in each request and prints Lift's answer for each one: a count for each outcome, such as stored and unknownDevice. It stops with an error, and exit code 1, if Lift refuses a request.
5. Accept the device#
After the first run, open the connection's Devices in the portal and choose Accept readings for the device. Lift does not store readings from a device until it is accepted, so the first answer counts them as unknownDevice.
Good to know#
- Lift takes at most 500 readings and 256 KB in one request; a larger body is refused with 413.
- The secret never appears on the command line or in the script's output: the script reads it from the protected file and never prints it.
- The script signs each request when it sends it. If Lift answers 429, it waits the number of seconds in the
Retry-Afterheader, signs again and tries up to four times in all. - Running the script twice with the same file is safe: readings Lift already has are counted as
duplicate, not stored twice. - Keep the machine's clock right: Lift refuses a signature more than 5 minutes away from its own time.