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>.ndjsonis exactly whatdata.exportwrites 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,_updatedAtand_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 asdata.importstamps any record, now and at version 1. Adata.importcall of your own always stamps.schema/<collection>.jsonis whatschema.getanswers for the collection (its rules, constraints andstrict). A restore declares it before the collection's first record, so each record goes back under the rules it was written under.index/<collection>.jsonis{"fields":...,"aggregates":...,"expiry":...,"id_derive":...}, each member exactly whatindex.listanswers 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.jsonis whatindex.list kind=spaceanswers. A restore declares each one again throughindex.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.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'sjetsis 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.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.code/versions/<n>.jsonis the version as the server stores it. A version names its function sources and site files by SHA-256; the matching bytes are incode/blobs/or, for the live site, insite/.site/opens in a browser straight from the unzipped folder.manifest.jsonlists 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:
- Check. The zip's directory, then every entry listed in
manifest.json, read whole: its size and SHA-256 must match, and with a passphrasesealed.binmust open. Any difference refuses the archive, and nothing is written. - 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 throughdata.importwith 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.