Node-RED Recipe for Lift JSON (beta)
Use this recipe when Node-RED already reads your temperatures (from a gateway, an MQTT broker or a serial port, for example) and you want it to send them to a Lift JSON connection. One function node builds and signs the request; Node-RED's own http request node sends it.
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; Lift does not run Node-RED for you. 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. Give Node-RED the web address and the secret#
Set two environment variables for the Node-RED process, for example in its service file or with docker run -e:
LIFT_SENSOR_URL: the connection's web address,https://api.openboxes.cloud/api/v1/sensors/ingest/<connection id>.LIFT_SENSOR_SECRET: the connection's secret. A space or line break copied with it is trimmed off.
The function node reads them with env.get, so the secret stays out of your flow and out of any flow you export. Restart Node-RED after setting them.
2. Add the function node#
Add a function node, name it "Sign for Lift" and paste this code into its On Message tab. It signs with plain JavaScript, so it needs no crypto module and nothing in the Setup tab.
// OpenBoxes Lift: sign readings for a Lift JSON sensor connection (Node-RED function node).
// A recipe to copy and adapt. Paste this whole file into a function node. Input: msg.payload
// is one reading, a list of readings, or { readings: [...] } (an object, or the same as a JSON
// string or Buffer); a reading is { device, at, temp, unit } as the Lift JSON docs describe,
// and any other field is left out. Output: a msg for an http request node set to
// "- set by msg.method -" with an empty URL. Set LIFT_SENSOR_URL and LIFT_SENSOR_SECRET as
// environment variables of Node-RED; keep the secret out of the flow itself.
// 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
var secret = env.get('LIFT_SENSOR_SECRET');
var url = env.get('LIFT_SENSOR_URL');
if (!secret || !String(secret).trim() || !url) {
node.error('Set LIFT_SENSOR_URL and LIFT_SENSOR_SECRET for Node-RED', msg);
return null;
}
var p = msg.payload;
try {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(p)) p = p.toString('utf8');
if (typeof p === 'string') p = JSON.parse(p);
} catch (err) {
node.error('msg.payload is not valid JSON', msg);
return null;
}
if (p === null || typeof p !== 'object') {
node.error('msg.payload must be a reading, a list of readings or { readings: [...] }', msg);
return null;
}
var readings = Array.isArray(p) ? p : (Array.isArray(p.readings) ? p.readings : [p]);
var body;
try {
body = liftBody(readings);
} catch (err) {
node.error(err.message, msg);
return null;
}
var t = Math.floor(Date.now() / 1000);
msg.method = 'POST';
msg.url = String(url).trim();
msg.headers = { 'Content-Type': 'application/json', 'X-Lift-Signature': liftSignature(secret, t, body) };
msg.payload = body;
return msg;
The node takes msg.payload as one reading, a list of readings, or an object with a readings list, either as an object or as the same JSON in a string or a Buffer. A reading is { "device": "fridge-1", "at": "2026-10-08T12:00:00Z", "temp": 4.5 }, with "unit": "F" for Fahrenheit, exactly as on the Lift JSON page. The recipe sends only device, at, temp and unit from each reading and leaves any other field out. If the two environment variables are missing, the payload is not JSON, is empty or not an object, or has more than 500 readings, the node reports an error and sends nothing.
3. Wire it to an http request node#
Connect the function node to an http request node set like this:
- Method:
- set by msg.method - - URL: leave it empty (the function node sets
msg.url). - Return:
a parsed JSON object
Add a debug node after it set to show the complete msg object, and watch msg.statusCode as well as msg.payload. A refused request (401, 413 or 429) does not stop the flow or raise an error in Node-RED: only the status code tells you. When it is 200, msg.payload holds Lift's answer, a count for each outcome such as stored and unknownDevice.
4. Accept the device#
After the first request, 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 function node signs each message when it passes through, and Lift accepts each signed request once. Two messages with exactly the same readings in the same second get the same signature, and Lift refuses the second one as already received.
- The http request node does not try again by itself. If Lift answers 429, send the same readings again after the number of seconds in the
Retry-Afterheader; the function node signs them again, and readings Lift already has are counted asduplicate, not stored twice. - Keep the clock of the machine running Node-RED right: Lift refuses a signature more than 5 minutes away from its own time.