App archives

Download your whole app as one `.substrio.zip`: a backup you can restore and an export you can read.

Docs menu

An archive is a plain zip, so it opens with a double click on any computer. Every record in it is NDJSON or JSON in the same form the API answers, so it imports into any server version, whatever storage format that server uses: the data travels in a universal form, never as database files.

Operations

Operation Route What it does
app.archive POST /v1/_archive/download Streams the app as a .substrio.zip. Optional JSON body: passphrase (at least 8 characters) seals credentials into sealed.bin; backup_id (an id from backup.list) archives that backup instead of the app as it is now
app.restore POST /v1/_archive Restores an archive (the request body) into the app you call it on. replace=true replaces an app that already holds data. Header X-Substrio-Passphrase opens sealed.bin
app.import POST /v1/_import The platform's app move: given an archive instead of a sealed bundle (the first bytes decide), it makes app slug (the archive's own app when omitted) from it. Takes the system key

Both app.archive and app.restore take the app's admin key. The download is read from one snapshot of the database, so every collection, the users and the credentials in it agree with each other. The live version, its stack and the other versions are read at the same moment.

What is in an archive

myapp-20261028.substrio.zip
  manifest.json              format, app, server version, created_at, secret_fields, jets, and every
                             entry's path, size, SHA-256
  data/<collection>.ndjson   one record a line, with its _id; a secret field is never in it
  schema/<collection>.json   the collection's declared schema, when it has one
  index/<collection>.json    the collection's index declarations, when it has any
  index/spaces.json          the unique spaces across collections, when there are any
  sql/schema.json            each SQL table's and view's CREATE statement, when the app has any
  sql/<table>.ndjson         one SQL row a line, a JSON object by column name
  kv/<store>.ndjson          one key-value record a line (kv.set's stores, and default: the Redis
                             and memcache keyspace)
  graph/<label>.ndjson       one node a line with its edges out under that label
  ledger/catalog.json        the ledger's account kinds and categories, in order
  ledger/entries.ndjson      every ledger entry, in ledger.import's form, under its own id
  data/_users.ndjson         each user's own object (name and profile fields), with its _id
  users.ndjson               each user: id, email, flags, role, keyring, metadata
  config/stack.json          {"live":[...], "sandboxes":[...]}
  config/secrets.json        {"names":[...]}: names only, never a value
  config/views.json          the app's views, when it has any
  config/ai_config.json      the AI settings, without provider keys
  code/versions/<n>.json     every stored version: functions, rules, cron, triggers, queues, plans,
                             auth and login settings, permissions, ledger catalog, declared collections
  code/blobs/<sha256>        each function source, rules and MCP file a version names
  site/<path>                the live version's site files at their served paths
  sealed.bin                 only with a passphrase: secret values and sign-ins, encrypted

Each file's form:

  • data/<collection>.ndjson is exactly what data.export writes for that collection, one record a line, every field but the secret ones. Read it with any JSON tool, or load it into another system as it is. A restore writes each record back with its own _id, _createdAt, _updatedAt and _version, in the one write that stores it: a line that lacks one of the four (or holds one that is not an id or a whole number) is stamped as data.import stamps any record, now and at version 1. A data.import call of your own always stamps.
  • schema/<collection>.json is what schema.get answers for the collection (its rules, constraints and strict). A restore declares it before the collection's first record, so each record goes back under the rules it was written under.
  • index/<collection>.json is {"fields":...,"aggregates":...,"expiry":...,"id_derive":...}, each member exactly what index.list answers for that kind and collection: the unique and indexed fields, keyword, element and compound indexes, the declared aggregates, the expiry and the id prefix. index/spaces.json is what index.list kind=space answers. A restore declares each one again through index.create: the id prefix before the collection's first record, the rest once every record is in, so each index is built once over the whole collection.
  • sql/schema.json is {"tables":[...],"views":[...]}. Each table: name, create (its SHOW CREATE TABLE text), and stored: rows with its columns in order, its binary columns and its rows count, or collection for a table stored as a collection, whose rows are its documents in data/<table>.ndjson. Each view: name and create, CREATE VIEW <name> AS <its SELECT>, the statement a restore runs (no ALGORITHM, DEFINER or SQL SECURITY clause).
  • sql/<table>.ndjson holds one row a line, {"id":1,"label":"tea","amount":"2.50","tag":"AP8Q","at":"2026-10-01 12:00:00"}: each cell as query.sql answers it (an integer past 2^53, a DECIMAL, DATE, TIME and DATETIME as their exact SQL text, a binary cell as base64). A restore makes every table from its create before the records (a table whose foreign key names one not made yet goes after the rest), puts each row back with one INSERT (a binary cell through FROM_BASE64), then makes each view.
  • kv/<store>.ndjson holds one record a line, in key order. A string value reads as text: {"k":"a","v":"1","cas":7}, with flags and exp (its expiry, milliseconds since 1970) when set. A key or value that is not UTF-8 goes as k_b64 or v_b64. A Redis value of another type (a hash, set, list, stream and their parts) goes as {"k":...,"raw_b64":...}: its exact stored bytes, which a restore writes back as they are. An expired record is left out.
  • graph/<label>.ndjson holds {"src":1,"dst":[2,3]} a line: a node and every node it has an edge to under that label, ascending.
  • ledger/entries.ndjson holds one entry a line, by name: {"_id":"...","kind":"wallet","party":"p1","currency":"USD","amount":500,"type":"CREDIT","status":"CLEARED","category":"sale","created_at":1761600000000,...}, with reference, scope, org, counterparty, original (a reversal's original, by its _id), clear_at, external_ref, description and data when the entry has them. A restore declares ledger/catalog.json first, then writes each entry under its own _id with its own created_at and status, so a record that names an entry still names it and every balance and total comes out the same.
  • A name in an entry's path (a collection, a store, a label) is written as itself when it holds only letters, digits, _, - and . (not first); any other byte is %XX, so a/b is a%2Fb.
  • manifest.json names, in secret_fields, each collection's fields declared secret ({"vault":["token"]}). Their values are never in data/: they are in sealed.bin when the archive has a passphrase, and nowhere in the archive otherwise.
  • manifest.json's jets is reserved for the data each featured app (SendJet, AuthJet, MapJet) keeps for this app. No server writes it yet, so it is always {}; a later server fills it, and this one passes it over on a restore.
  • users.ndjson is one user.get object a line (user_id, email, created_at, email_verified, locked, mfa_enabled, disabled, admin, role, keyring, metadata, has_password). It never holds a password hash.
  • code/versions/<n>.json is the version as the server stores it. A version names its function sources and site files by SHA-256; the matching bytes are in code/blobs/ or, for the live site, in site/.
  • site/ opens in a browser straight from the unzipped folder.
  • manifest.json lists every other entry. On a restore each entry is read and its SHA-256 compared before anything is written.

Files that are compressed already (images, fonts, video, .zip, .gz, .wasm, .pdf) are stored as they are; everything else is deflated.

What a passphrase adds

Without a passphrase, an archive never holds anything that signs in or unlocks: secret values, password hashes, TOTP enrolments, passkeys and the values of fields declared secret are left out. A restore of it brings back every user with their id, email and profile, but none can sign in with a password until they reset it; secrets_missing names each secret to set again, and secret_fields_missing each secret field ("vault.token") whose values stayed behind.

With a passphrase, those go into sealed.bin, encrypted with AES-256-GCM under a key made from the passphrase by Argon2id (64 MiB, 3 passes, a random salt). A secret field's values travel there one record a line, by collection, the record's _id and its line in data/<collection>.ndjson. A restore with the same passphrase brings back the secrets, writes each secret field back into its own record, and every user signs in exactly as before, with the same password, second factor and passkeys. Keep the passphrase: nobody can open sealed.bin without it, and the server keeps no copy.

Restoring

app.restore reads the archive in two passes:

  1. Check. The zip's directory, then every entry listed in manifest.json, read whole: its size and SHA-256 must match, and with a passphrase sealed.bin must open. Any difference refuses the archive, and nothing is written.
  2. Write. Through the same paths an app uses every day: blobs and site files, versions, views and AI settings, secrets, users under their own ids (records and rules that name a user keep pointing at them), each declared schema (collection.create, schema.update), each id prefix (index.create kind=id_derive), each SQL table, every collection through data.import with its sealed secret fields put back into their records on the way, the key-value stores (each record's exact bytes), the ledger (catalog, then every entry under its own id), the graph edges, the SQL rows (in batches of 4 MiB of lines, each batch one transaction) and views, every other index declaration (index.create), then the live version is made live. The live version goes last, so no trigger fires on an imported record.

The answer counts what was restored:

{"app":"myapp","created":false,"collections":[{"name":"notes","records":3,"rejected":0}],
 "records":3,"rejected":0,"schemas":1,"indexes":2,"secret_values":1,"secret_fields_missing":[],
 "kv":[{"name":"cache","records":3}],"sql":{"tables":1,"views":1,"rows":[{"table":"prices","rows":2}]},"graph":[{"label":"follows","edges":3}],"ledger_entries":2,
 "users":1,"users_without_password":0,"users_failed":[],
 "versions":2,"live":2,"blobs":4,"sealed":"opened","secrets":["GREETING"],"secrets_missing":[],
 "sandboxes":[],"sandboxes_failed":[]}

sealed is none (the archive has no sealed part), opened, or left_out (it has one and no passphrase was sent). app.import answers the same, plus the new app's key the first time.

Refusals

Code Status When
ARCHIVE_INVALID 400 Not a zip, no manifest.json, an entry missing, cut short, or not matching its size or SHA-256, an unsafe entry name, an archive format newer than the server reads, or a sealed.bin that opens with the passphrase but holds a line of no known kind. detail names the entry. Nothing was written
ARCHIVE_PASSPHRASE_WRONG 400 X-Substrio-Passphrase does not open sealed.bin. Send the passphrase the archive was downloaded with, or none to restore without the sealed part. Nothing was written
INVALID_ARGUMENT 400 passphrase shorter than 8 characters (field: passphrase), or backup_id not a string
KEY_NOT_FOUND 404 backup_id names no completed backup (field: backup_id)
CONFLICT 409 The app already holds data and replace=true was not sent
REQUEST_TOO_LARGE 413 The archive is larger than the server's --import-max-bytes
INSUFFICIENT_STORAGE 507 Not enough free disk to snapshot the app for the download

Example: download, read, restore into a new app

# Download, with a passphrase so the restore keeps sign-ins and secrets.
curl -sS -X POST https://api.substrio.com/v1/_archive/download \
  -H "Authorization: Bearer $KEY" -H "X-Substrio-Tenant: myapp" \
  -H "Content-Type: application/json" -d '{"passphrase":"correct horse battery"}' \
  -o myapp.substrio.zip

# Read a collection: plain NDJSON.
unzip -q myapp.substrio.zip -d myapp
head -3 myapp/data/notes.ndjson

# Make a new app, then restore the archive into it.
substrio apps create myapp-copy
curl -sS -X POST https://api.substrio.com/v1/_archive \
  -H "Authorization: Bearer $COPY_KEY" -H "X-Substrio-Tenant: myapp-copy" \
  -H "X-Substrio-Passphrase: correct horse battery" \
  -H "Content-Type: application/zip" --data-binary @myapp.substrio.zip

In the console, an app's Backups page has Download app (with the optional passphrase) and a Download button on each backup; the account's Apps page has Import app, which makes a new app and restores an archive into it.

Cost

A download is one pass over the app: each record is rendered once, then checksummed and deflated as it streams, with about 0.6 MiB of memory in use whatever the app's size (one 256 KiB piece, the deflate state and one record). A collection with secret fields is still walked once: the secret values go aside as the walk finds them (written once to a nameless scratch file, read once into sealed.bin), never a second walk.

The snapshot it reads is taken first. For the app as it is now, that is the copy a replica makes when it joins: the database's used extents written once to a work folder next to the app, then the writes made meanwhile replayed. That is a full copy of the used bytes on ext4 and on every filesystem, never of the file's holes. For a backup (backup_id), nothing writes to it, so it is cloned instead: a reflink where the filesystem has one (XFS, Btrfs, APFS), a copy of its used extents on ext4.

A SQL row table is read with one SELECT * through the engine's streaming read, written once to a work file next to the snapshot, 256 KiB at a time, then streamed into the zip like a site file: the table's row bytes go to disk once more, and memory stays bounded.

A restore reads the archive twice, once to check it and once to write it, holding at most 4 MiB of records at a time.

Not in an archive yet

The data each featured app (SendJet, AuthJet, MapJet) keeps for this app is not written yet: jets in manifest.json is its reserved place. Records in a namespace (X-Substrio-Namespace) are not written yet either, nor a vector index declaration (index.create kind=vector): declare it again on the restored app. SQL stored functions and procedures are not written yet either. A user's link to an outside sign-in (Google, GitHub) is not carried: the user signs in that way again, or by email.