The backup format.
.qlbak version 2 (version 1 still described). Page dated 9 November 2026.
A Quietlift backup is a single file you can keep anywhere. Its format is documented here in full, so you, or any programmer, can read your data without us. You are never locked in.
The same file is used for manual backups, automatic backups, restoring, and moving between iPhone and Android. Backups are named quietlift-YYYYMMDD-HHmm.qlbak, for example quietlift-20261106-1830.qlbak.
Overview
- A key is derived from your backup passphrase with PBKDF2-HMAC-SHA256.
- That key opens an AES-256-GCM envelope.
- Inside is an ordinary zip file with uncompressed (stored) entries:
manifest.jsonandexport.json. export.jsonholds everything you made: exercises, routines, workouts, body weight, conditioning and saved timers.
Per-phone preferences such as unit, theme and accent colour are not in the backup.
Byte layout
| Offset | Size | Field |
|---|---|---|
| 0 | 6 | Magic: ASCII QLBAK followed by one zero byte (51 4C 42 41 4B 00) |
| 6 | 1 | Format version, unsigned. 2 for this specification. |
| 7 | 4 | PBKDF2 iteration count, unsigned, big-endian. Quietlift writes 600,000 (00 09 27 C0). |
| 11 | 16 | PBKDF2 salt, random per passphrase |
| 27 | 2 | Hint length h in bytes, unsigned, big-endian. 00 00 when there is no hint. |
| 29 | h | Passphrase hint, UTF-8, exactly as the person typed it (at most 60 characters). Readable without the passphrase. |
| 29 + h | 12 | AES-GCM nonce, random per file |
| 41 + h | n | AES-256-GCM ciphertext of the zip |
| 41 + h + n | 16 | AES-GCM authentication tag |
Bytes 0 to 28 + h (magic, version, iterations, salt, hint length and hint) are the header. The header is not encrypted, but it is authenticated: it is passed to AES-GCM as associated data, so the hint can be read without the passphrase but not changed without breaking the file. The nonce is not part of the associated data.
Version 1 (files written before 9 November 2026) has no hint fields: the header is bytes 0 to 26, the nonce starts at byte 27 and the ciphertext at byte 39. Everything else is the same. Quietlift reads both.
Key and encryption
- Key: PBKDF2 with HMAC-SHA256, password = the passphrase encoded as UTF-8, salt = bytes 11–26, iterations = bytes 7–10, output length 32 bytes.
- Cipher: AES-256-GCM with the 12-byte nonce, a 128-bit tag, and the whole header as associated data. Most libraries take the ciphertext and tag together as one byte string (everything after the nonce, to the end).
- A wrong passphrase, a changed header or a damaged file all fail the same way: the tag doesn’t verify.
- The passphrase is at least 8 characters. Quietlift cannot recover it. The optional hint may not contain it.
Reading rules
- Refuse a file whose first 6 bytes are not the magic.
- Refuse a version you don’t know. A later format will carry a higher version number and be documented on this page.
- Refuse a hint length that runs past the end of the file.
- Refuse an iteration count outside 1 to 10,000,000 before deriving anything. Quietlift does, so a crafted header can’t keep a phone busy for minutes.
- After decrypting, check every member listed in
manifest.jsonagainst its SHA-256. Ignore members you don’t know; a later version may add one.
The zip
A plain zip with stored (method 0, uncompressed) entries and UTF-8 names. The whole zip is encrypted, so compression would add nothing. Once decrypted, any unzip tool opens it. Versions 1 and 2 contain the same members (the hint changed only the header; manifest.json still says formatVersion 1):
manifest.json: what wrote the file and what it holds.export.json: the data.
manifest.json
| Field | Type | Meaning |
|---|---|---|
formatVersion | integer | 1 |
appVersion | string | The Quietlift version that wrote the file |
createdAt | string | ISO-8601 instant, UTC |
device | string | A plain device name, such as “Pixel 9” or “iPhone” |
counts | object | workouts, sets, routines, exercises, bodyWeights, conditioning: integers |
members | array | One object per zip member: name, sha256 (lowercase hex), size (bytes) |
{
"formatVersion": 1,
"appVersion": "1.0.0",
"createdAt": "2026-11-06T18:30:00Z",
"device": "Pixel 9",
"counts": { "workouts": 212, "sets": 9431, "routines": 4, "exercises": 61, "bodyWeights": 40, "conditioning": 12 },
"members": [
{ "name": "export.json", "sha256": "6edf3a4b…", "size": 1843302 }
]
}
export.json
The same document as More › Your data › Quietlift JSON, which you can export without encryption at any time.
{
"format": "quietlift-export",
"schemaVersion": 1,
"exportedAt": "2026-11-06T18:30:00Z",
"signature": "…",
"data": {
"exercises": [ … ],
"routines": [ … ],
"workouts": [ … ],
"bodyWeights": [ … ],
"conditioning": [ … ],
"savedTimers": [ … ]
}
}
- Weights are always kilograms, whatever unit you use in the app. Distances are metres, durations seconds.
- Times are ISO-8601 instants in UTC. Local dates are worked out when shown, using the phone’s time zone.
- Ids are strings (UUIDs for things you made, readable slugs such as
bench-pressfor the built-in library). They are stable across phones. - Missing fields: a field that is empty, null or at its default (
false, an empty list) is left out. Treat a missing field as its default. signatureis an HMAC that Quietlift uses to recognise its own exports when they are imported again. You can ignore it. A file whose data was edited no longer matches it and is imported like a file from another app.- Ignore keys you don’t know. A later schema version may add some.
exercises[]
| Field | Meaning |
|---|---|
id | Stable id |
nameKey | Library exercises: a name key such as exercise_bench_press, shown in the app’s language |
customName | Your own exercises: the name you gave it |
primaryMuscle, secondaryMuscles | chest, shoulders, triceps, biceps, forearms, lats, upper_back, lower_back, traps, abs, glutes, quads, hamstrings, calves, adductors, abductors, neck, full_body |
equipment | barbell, dumbbell, machine, cable, bodyweight, kettlebell, ez_bar, smith_machine, band, trap_bar, plate, other |
kind | weighted, bodyweight, assisted, duration, distance |
origin | library, user (made in the app) or import |
archived | Boolean |
aliases | Other names, as strings |
routines[]
| Field | Meaning |
|---|---|
id, name, origin | As above; origin is user or import |
position | Order in your list |
archived | Boolean |
exercises[] | In order: exerciseId, targetSets, repsMin, repsMax (absent for a fixed rep target), restSeconds, supersetGroup (exercises sharing a number form a superset), note, nextWeightKg |
workouts[]
| Field | Meaning |
|---|---|
id, name | The workout and its title |
routineId | The routine it was started from, if any |
startedAt, finishedAt | Instants |
exercises[] | In order: exerciseId, supersetGroup, restSeconds, targetRepsMin, targetRepsMax, note, sets[] |
sets[] | In order: type (normal, warmup, drop, failure), weightKg, reps, durationSeconds, distanceMeters, rpe, completedAt |
bodyWeights[], conditioning[], savedTimers[]
| List | Fields |
|---|---|
bodyWeights | loggedAt, kg |
conditioning | Finished interval blocks: id, presetKey (a built-in preset) or customName, durationSeconds, rounds, note, workoutId (if done inside a workout), endedAt |
savedTimers | Your interval timers: id, name, round (a list of {kind, seconds} with kind one of prep, work, rest), rounds, createdAt |
A decoder in Python
About fifty lines, using the standard library and the cryptography package (pip install cryptography). It opens a backup, checks the hashes and returns both JSON documents. Download read_qlbak.py.
# read_qlbak.py: open a Quietlift .qlbak (format v1 or v2) with your passphrase.
# Needs Python 3.8+ and the "cryptography" package (pip install cryptography).
import getpass, hashlib, io, json, struct, sys, zipfile
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def read_header(data):
if data[:6] != b"QLBAK\x00":
raise ValueError("not a .qlbak file")
version = data[6]
if version not in (1, 2):
raise ValueError(f"format version {version}; this reader knows versions 1 and 2")
(iterations,) = struct.unpack(">I", data[7:11]) # big-endian
if not 1 <= iterations <= 10_000_000:
raise ValueError("implausible iteration count")
salt = data[11:27] # 16 bytes
end, hint = 27, ""
if version == 2:
(size,) = struct.unpack(">H", data[27:29]) # hint length, big-endian
end = 29 + size
if end + 12 + 16 > len(data):
raise ValueError("hint length runs past the file")
hint = data[29:end].decode("utf-8")
return iterations, salt, hint, end
def open_qlbak(path, passphrase):
data = open(path, "rb").read()
iterations, salt, hint, end = read_header(data)
header = data[:end] # authenticated, not encrypted
nonce = data[end:end + 12] # 12 bytes
sealed = data[end + 12:] # ciphertext followed by the 16-byte tag
key = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
plain = AESGCM(key).decrypt(nonce, sealed, header) # raises InvalidTag on a wrong passphrase
members = zipfile.ZipFile(io.BytesIO(plain))
manifest = json.loads(members.read("manifest.json"))
for m in manifest["members"]:
body = members.read(m["name"])
if hashlib.sha256(body).hexdigest() != m["sha256"]:
raise ValueError(f"{m['name']} does not match its hash")
export = json.loads(members.read("export.json"))
return manifest, export
if __name__ == "__main__":
hint = read_header(open(sys.argv[1], "rb").read())[2]
if hint:
print("Hint:", hint)
manifest, export = open_qlbak(sys.argv[1], getpass.getpass("Passphrase: "))
print(json.dumps(manifest, indent=2))
print(len(export["data"]["workouts"]), "workouts")
Run it as python3 read_qlbak.py quietlift-20261106-1830.qlbak. With a wrong passphrase, decrypt raises InvalidTag. The key derivation takes a moment; that is the 600,000 iterations doing their job.
We tested this script against a sample .qlbak written by the app before publishing it.
Why it’s published
Quietlift has no account and no server, so your backup is the only copy that isn’t on your phone. A file you can only open with one app isn’t fully yours. With this page, it is.
Questions or a mistake on this page: support@quietlift.app.