App archives

Download your app as an archive. You can restore the archive as a backup or read it as an export.

Docs menu

The archive uses the .substrio.zip format.

An archive is a ZIP file. Its records use NDJSON or JSON in the form returned by the API, rather than database files. This lets a server import the data independently of its storage format.

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

Both app.archive and app.restore require the app's admin key. The download reads one database snapshot, so collections, users, and credentials are consistent with each other. With backup_id, versions, the live stack, uploaded files, and site bytes come from that backup. Later changes to the app do not change the archive's saved version or live site.

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
  jets/<id>/data/<name>.ndjson    a jet's collection in this app, in the form of data/
  jets/<id>/schema/<name>.json    its declared schema
  jets/<id>/index/<name>.json     its index declarations
  jets/<id>/kv/<store>.ndjson     a jet's key-value store in this app, in the form of kv/
  files/paths.ndjson         each uploaded file's path, blob, content_type, cache_control, public, and owner
  files/blobs/<xx>/<hash>    each distinct uploaded file payload, under its full content hash
  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/<address>       each source, rules, MCP, and saved site blob not placed under site/
  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 contains the data.export output for that collection, one record per line, excluding secret fields. You can read it with a JSON tool or load it into another system. A restore writes each record with its _id, _createdAt, _updatedAt, and _version. If a line lacks any of these four fields, or a field is not a valid id or whole number, the record is stamped with the current time and version 1 as in data.import. A direct data.import call always stamps records.
  • schema/<collection>.json contains the rules, constraints, and strict value returned by schema.get. A restore declares the schema before the collection's first record, so records are restored under their schema rules.
  • index/<collection>.json is {"fields":...,"aggregates":...,"expiry":...,"id_derive":...}. Each member matches the corresponding index.list result for the collection: unique and indexed fields, keyword, element, and compound indexes, declared aggregates, expiry, and id prefix. index/spaces.json contains the index.list kind=space result. A restore redeclares these through index.create, with the id prefix before the first record and the remaining indexes after all records are restored. Each index is built once over the 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 names each jet that keeps data in this app, by its permanent id: {"replyjet":{"collections":["threads"],"stores":["seen"]}}. The records are in jets/<id>/, in the same forms as an app's own entries. The id links them to the jet. It survives a rename of the jet and a move to another server (featured apps).
  • 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.
  • files/paths.ndjson holds one uploaded file per line. Each row names its path and full blob hash, content type, cache control, public flag, and owner. The matching bytes are in files/blobs/<xx>/<hash>, where <xx> is the hash's first two characters. Paths that share a payload share one archive entry. A restore writes each file through the file store, preserving its path and settings with fresh creation and update stamps. Restored files remain available when the original app has been deleted.
  • code/versions/<n>.json is the version as the server stores it. A version names its function sources and site files by their GxHash-128 content name. The matching bytes are in code/blobs/ under the full 64-digit blob address, which starts with that name, or, for the live site, in site/.
  • site/ opens in a browser straight from the unzipped folder.
  • manifest.json lists every other entry. During restore, each entry is read and its SHA-256 checked 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, the archive excludes secret values, password hashes, TOTP enrollments, passkeys, and values of fields declared secret. Restoring it brings back users with their ids, email addresses, and profiles. They cannot sign in with a password until they reset it. secrets_missing lists secrets to set again, and secret_fields_missing lists secret fields, such as "vault.token", whose values were left out.

With a passphrase, those values are stored in sealed.bin, encrypted with AES-256-GCM under a key derived from the passphrase by Argon2id (64 MiB, 3 passes, and a random salt). Secret field values are stored one record per line, identified by collection, record _id, and line in data/<collection>.ndjson. Restoring with the same passphrase restores the secrets and secret fields. Users can sign in with their previous passwords, two-factor authentication methods, and passkeys. Keep the passphrase: it is required to open sealed.bin, and the server does not keep a copy.

Restoring

app.restore reads the archive in two passes:

  1. Check. The ZIP directory and each entry listed in manifest.json are read in full. Each entry's size and SHA-256 must match. When a passphrase is supplied, sealed.bin must also open. A failed check refuses the archive before anything is written.
  2. Write. Restore uses the ordinary platform write paths. It restores blobs and site files, versions, views, AI settings, secrets, and users under their original ids. It then declares schemas with collection.create and schema.update, declares id prefixes with index.create kind=id_derive, and creates SQL tables. Collections are imported through data.import, with sealed secret fields restored into their records. The restore then writes key-value records as their exact bytes, the ledger catalog and entries under their original ids, graph edges, SQL rows and views, and remaining index declarations. SQL rows are written in batches of 4 MiB of lines, each batch in one transaction. The live version is activated last, so no trigger fires on an imported record.

The response 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":[],"warnings":[]}

sealed is none if the archive has no sealed part, opened if it was opened, or left_out if it was present but no passphrase was sent.

A jet's data is restored with the app's, and its collections are listed in collections by their full names. When no app on this server declares an id in the archive, the data is still restored and stays in the app. warnings then holds {"code":"JET_NOT_ON_SERVER","jet":"<id>"} for that id. A jet that declares reattach receives a message so that it can write its own rows for the app, and warnings holds {"code":"JET_REATTACH_NOT_SENT","jet":"<id>"} when that message could not be sent. See When an app moves to a new id.

Refusals

Code Status When
ARCHIVE_INVALID 400 A named code, site, or uploaded file blob is missing during download. During restore, the archive is not a ZIP, lacks manifest.json, has an unsafe, unsupported, or missing entry, fails its size or SHA-256 check, uses a newer archive format, or has an unreadable record inside an authenticated sealed.bin. The download is refused before streaming. A restore is refused before writing
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 makes one pass over the app. Each record is rendered once, then checksummed and deflated as it streams. The working memory includes a 256 KiB piece, deflate state, and one record. A collection with secret fields is still walked once. Secret values are written to an unnamed scratch file during the walk and read once into sealed.bin.

The snapshot is taken first. For the current app, it uses the same copy a replica makes when joining. The database's used extents are written to a work folder next to the app, then writes made during the copy are replayed. This copies used bytes without copying file holes. A backup selected by backup_id is cloned instead, using a reflink where available (XFS, Btrfs, or APFS) or copying 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 archive does not yet include data that SendJet and MapJet still keep in their own apps for this app. Records in a namespace (X-Substrio-Namespace) and vector index declarations (index.create kind=vector) are also omitted. Declare vector indexes again on the restored app. SQL stored functions and procedures are omitted as well. Links to outside sign-in providers such as Google and GitHub are not carried; users can sign in through the provider again or use email.