OpenFn Recipe for Lift JSON (beta)

Use this recipe when an OpenFn workflow already collects your temperatures and you want it to send them to a Lift JSON connection. It is one job step on OpenFn's http adaptor, version 7.0.0 or later (the version whose post takes a path, a body and options), that signs the readings and posts them.

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 OpenFn for you. Send a test reading first and check the answer.

This job sends Lift JSON. It is not the WHO EMS push from a fridge's own monitoring system, which Lift does not accept yet.

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. The connection id is the last part of the web address. See Lift JSON for the body, the signature and every answer Lift can give.

1. Add a credential#

In your OpenFn project, add a Raw JSON credential (a space or line break copied with the secret is trimmed off):

{
  "baseUrl": "https://api.openboxes.cloud",
  "liftConnectionId": "<connection id>",
  "liftSecret": "<your connection secret>"
}

2. Add the step#

Add a step that uses the http adaptor at version 7.0.0 or later, give it the credential, and paste this job:

// OpenBoxes Lift: send readings to a Lift JSON sensor connection from an OpenFn job.
// A recipe to copy and adapt, for a step on the http adaptor (@openfn/language-http 7.0.0 or
// later, whose post takes (path, body, options)). Input: state.data 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 step's credential (Raw JSON) holds:
//   { "baseUrl": "https://api.openboxes.cloud", "liftConnectionId": "<connection id>", "liftSecret": "<secret>" }
// This job posts Lift JSON. It is not the WHO EMS push, which Lift does not accept yet.

// 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

fn(state => {
  const config = state.configuration;
  const data = state.data;
  const readings = Array.isArray(data) ? data : (data && Array.isArray(data.readings) ? data.readings : []);
  const body = liftBody(readings);
  const t = Math.floor(Date.now() / 1000);
  state.lift = {
    path: '/api/v1/sensors/ingest/' + String(config.liftConnectionId).trim(),
    body: body,
    signature: liftSignature(config.liftSecret, t, body),
  };
  return state;
});

post(
  state => state.lift.path,
  state => state.lift.body,
  state => ({
    headers: { 'Content-Type': 'application/json', 'X-Lift-Signature': state.lift.signature },
    parseAs: 'json',
  })
);

fn(state => {
  delete state.lift;
  return state;
});

The job signs with plain JavaScript, so it needs no crypto module. It uses only the adaptor's fn and post.

3. Give it readings#

The step reads state.data: a list of readings, or an object with a readings list, for example from an earlier step that reads your source, or the body of a webhook trigger. 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. Send 1 to 500 readings in one run; anything else stops the step with an error and sends nothing.

After the step, state.data holds Lift's answer: a count for each outcome, such as stored and unknownDevice.

4. 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.
  • Each run signs its request when it runs, and Lift accepts each signed request once.
  • If Lift answers 429 or another error, the step fails. Run it again after a short wait: it signs the readings again, and readings Lift already has are counted as duplicate, not stored twice.
  • Keep the secret in the credential, never in the job code. The signature header is not a secret: it works only once.