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>.ndjsoncontains thedata.exportoutput 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 indata.import. A directdata.importcall always stamps records.schema/<collection>.jsoncontains the rules, constraints, andstrictvalue returned byschema.get. A restore declares the schema before the collection's first record, so records are restored under their schema rules.index/<collection>.jsonis{"fields":...,"aggregates":...,"expiry":...,"id_derive":...}. Each member matches the correspondingindex.listresult for the collection: unique and indexed fields, keyword, element, and compound indexes, declared aggregates, expiry, and id prefix.index/spaces.jsoncontains theindex.list kind=spaceresult. A restore redeclares these throughindex.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.jsonis{"tables":[...],"views":[...]}. Each table:name,create(itsSHOW CREATE TABLEtext), andstored:rowswith itscolumnsin order, itsbinarycolumns and itsrowscount, orcollectionfor a table stored as a collection, whose rows are its documents indata/<table>.ndjson. Each view:nameandcreate,CREATE VIEW <name> AS <its SELECT>, the statement a restore runs (noALGORITHM,DEFINERorSQL SECURITYclause).sql/<table>.ndjsonholds one row a line,{"id":1,"label":"tea","amount":"2.50","tag":"AP8Q","at":"2026-10-01 12:00:00"}: each cell asquery.sqlanswers it (an integer past 2^53, aDECIMAL,DATE,TIMEandDATETIMEas their exact SQL text, a binary cell as base64). A restore makes every table from itscreatebefore the records (a table whose foreign key names one not made yet goes after the rest), puts each row back with oneINSERT(a binary cell throughFROM_BASE64), then makes each view.kv/<store>.ndjsonholds one record a line, in key order. A string value reads as text:{"k":"a","v":"1","cas":7}, withflagsandexp(its expiry, milliseconds since 1970) when set. A key or value that is not UTF-8 goes ask_b64orv_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>.ndjsonholds{"src":1,"dst":[2,3]}a line: a node and every node it has an edge to under that label, ascending.ledger/entries.ndjsonholds one entry a line, by name:{"_id":"...","kind":"wallet","party":"p1","currency":"USD","amount":500,"type":"CREDIT","status":"CLEARED","category":"sale","created_at":1761600000000,...}, withreference,scope,org,counterparty,original(a reversal's original, by its_id),clear_at,external_ref,descriptionanddatawhen the entry has them. A restore declaresledger/catalog.jsonfirst, then writes each entry under its own_idwith its owncreated_atand 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, soa/bisa%2Fb. manifest.jsonnames, insecret_fields, each collection's fields declaredsecret({"vault":["token"]}). Their values are never indata/: they are insealed.binwhen the archive has a passphrase, and nowhere in the archive otherwise.manifest.json'sjetsnames each jet that keeps data in this app, by its permanent id:{"replyjet":{"collections":["threads"],"stores":["seen"]}}. The records are injets/<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.ndjsonis oneuser.getobject 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.ndjsonholds 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 infiles/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>.jsonis 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 incode/blobs/under the full 64-digit blob address, which starts with that name, or, for the live site, insite/.site/opens in a browser straight from the unzipped folder.manifest.jsonlists 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:
- Check. The ZIP directory and each entry listed in
manifest.jsonare read in full. Each entry's size and SHA-256 must match. When a passphrase is supplied,sealed.binmust also open. A failed check refuses the archive before anything is written. - 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.createandschema.update, declares id prefixes withindex.create kind=id_derive, and creates SQL tables. Collections are imported throughdata.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.