# Z8Data API reference Z8Data provides SQLite, Valkey KV, and File Storage over REST. This reference describes the current routes, authentication, requests, responses, limits, and errors. The public copy is at `/docs/reference.md`. `/llms.txt` links to it. Both support GET and HEAD without a key. Each project has two private Project API keys: one with the read-only role and one with the read/write role. A Project API key covers its KV namespace, files, and all databases in its project. The Admin API key can manage all projects and read telemetry. Project API keys cannot authorize Admin API or Telemetry API requests. The Admin API key cannot authorize Data API requests. The health endpoint needs no key. ## Contents - [Build a client](#build-a-client) - [Route index](#route-index) - [Project authentication](#project-authentication) - [Data API](#data-api) - [Database identity](#database-identity) - [SQL query request](#post-apidbv1projectsprojectidquery) - [Parameter and result values](#parameter-and-result-values) - [Results and direct write counts](#results-and-direct-write-counts) - [Transactions and rollback](#transactions-and-rollback) - [PRAGMAs and SQL rules](#pragmas-and-sql-rules) - [Limits and waiting](#limits-and-waiting) - [SQL HTTP rules](#http-caching-and-cors) - [Database management](#database-management) - [Valkey KV API](#valkey-kv-api) - [KV request and validation](#kv-request-and-validation) - [KV values and results](#kv-values-and-results) - [Browse KV keys](#browse-kv-keys) - [Inspect and refresh KV keys](#inspect-and-refresh-kv-keys) - [Atomic KV counters](#atomic-kv-counters) - [KV batches and failures](#kv-batches-and-failures) - [KV errors](#kv-errors) - [KV HTTP rules](#kv-http-rules) - [KV availability and project cleanup](#kv-availability-and-project-cleanup) - [File Storage API](#file-storage-api) - [Backup and restore](#backup-and-restore) - [Admin API](#admin-api) - [Admin routes](#admin-routes) - [Project and key response fields](#project-and-key-response-fields) - [Project lifecycle](#project-lifecycle) - [Admin errors](#admin-errors) - [Telemetry API](#telemetry-api) - [Health endpoint](#health-endpoint) - [Database errors](#database-errors) ## Build a client Use your Z8Data service URL. SQLite, Valkey KV, and File Storage requests use a project ID and a Project API key. File Storage also supports temporary grants for content transfers. If you have an Admin API key, use POST `/api/admin/v1/projects` with `{ "name": "My app" }` to create a project and get its ID and keys. Examples use `https://z8data.com`, project ID `abcdefghijkl`, and placeholder secrets. Replace them with your own values. For SQLite: 1. Send POST `/api/db/v1/projects/{projectId}/databases` with exactly `{ "filename": "app.db" }` and the read/write Project API key. Wait for 201. 2. Send POST `/api/db/v1/projects/{projectId}/query` with exactly `filename` and a nonempty `statements` array. Each statement has `sql` and optional `params`. Use the read/write Project API key for schema and data changes. 3. Use the read-only Project API key for SQL reads, listing databases, and checking one database. Each SQL batch is one transaction. For Valkey KV, send POST `/api/kv/v1/projects/{projectId}/query` with `{ "operations": [{ "op": "get", "key": "example" }] }` and either Project API key. Use the read/write Project API key for writes. KV needs no database creation call. Read the [KV batch rules](#kv-batches-and-failures) before sending writes. Runtime KV errors can leave changes in place. To discover KV keys, send POST `/api/kv/v1/projects/{projectId}/keys` with `{}` and the read-only Project API key. Follow the returned cursor until `done` is true. See [Browse KV keys](#browse-kv-keys) for filtering and continuation. For File Storage, generate a lowercase UUIDv4 file ID and upload raw bytes with PUT `/api/files/v1/projects/{projectId}/files/{fileId}/content` using the read/write Project API key and the required metadata headers. Use either Project API key to list files, read metadata, or retrieve content. Your application can issue temporary upload or download grants after checking end-user permissions. See the [File Storage API](#file-storage-api) for headers, routes, and grant rules. Check the HTTP status before reading a response as a success object. HEAD and OPTIONS responses have no body; do not call `response.json()` on them. Errors can contain an `error` object, an `error` string, or a non-JSON body. SQL errors use `statementIndex`. KV errors use `operationIndex`. Admin project errors have no index. See the error rules for the API you call. Do not add automatic retries for writes, project creation, key replacement, or deletion; a lost response can leave the result unknown. ## Route index The URL selects the project on SQLite, Valkey KV, and File Storage routes. Replace `{projectId}` with your project ID. The Project API key sent as the Bearer token must belong to that project. Requests accept only the listed fields. A database identity contains only `filename`. | Method | Route | Key role | Success | | --- | --- | --- | --- | | POST | `/api/db/v1/projects/{projectId}/query` | Either; writes need read/write | 200 `{ results }` | | POST | `/api/kv/v1/projects/{projectId}/query` | Either; writes need read/write | 200 `{ results }` | | POST | `/api/kv/v1/projects/{projectId}/keys` | Either | 200 `{ keys, cursor, done }` | | POST | `/api/db/v1/projects/{projectId}/databases` | Read/write | 201 `{ database }` | | GET, HEAD | `/api/db/v1/projects/{projectId}/databases` | Either | 200 `{ databases }` | | GET, HEAD | `/api/db/v1/projects/{projectId}/databases?filename=app.db` | Either | 200 `{ database }` | | DELETE | `/api/db/v1/projects/{projectId}/databases?filename=app.db` | Read/write | 200 `{ deleted, database }` | | GET, HEAD | `/api/health` | None | 200 `{ status }` | Here, "either" means either Project API key. See the [File Storage route table](#file-storage-api) for uploads, file listing, metadata, content, deletion, and temporary grants. Admin routes use the Admin API key: | Route | Methods besides OPTIONS | Purpose | | --- | --- | --- | | `/api/admin/v1/project-store` | GET, HEAD | Read project access status. | | `/api/admin/v1/projects` | GET, HEAD, POST, PATCH, DELETE | List, read, create, rename, or delete projects. | | `/api/admin/v1/project-keys` | GET, HEAD, POST | Read or replace Project API keys. | | `/api/admin/v1/databases` | GET, HEAD, POST, DELETE | Manage a project's databases. | | `/api/admin/v1/telemetry` | GET, HEAD | Read host and request metrics. | See [Admin routes](#admin-routes) for request and response fields. Every API route in these tables supports public OPTIONS. The method, body, and CORS rules differ by API and are listed in each section. ## Project authentication Send `Authorization: Bearer ` or `Authorization: Bearer ` on every SQL, database management, and KV request except OPTIONS. File Storage uses the same Project API keys and also accepts temporary grants on content routes; see [File Storage API](#file-storage-api). Use one space after Bearer. The scheme is case-insensitive; the token is exact. Extra whitespace between Bearer and the token, commas, duplicate headers, and noncanonical tokens are rejected. Leading and trailing header whitespace may be removed before authentication. Project API keys are canonical lowercase UUIDv4 values. Each has 36 characters, including hyphens, with no prefix. Treat the complete UUID as a secret. The [Admin API key](#admin-api) has a separate format. Keep keys private. Do not put them in browser code, URLs, logs, analytics, or Git. Your app handles its own end-user login and permissions. Project IDs are public identifiers of exactly 12 lowercase letters or digits. Send the ID in the URL and the matching key in the Authorization header. An Admin API client can read Project API keys with GET `/api/admin/v1/project-keys?projectId={projectId}`. POST `/api/admin/v1/project-keys` with `{ "projectId": "abcdefghijkl" }` replaces both keys together. After it returns success, every later request using either old key fails. Key rotation waits for accepted requests to finish. Other projects can keep working. Do not automatically retry project creation or key rotation after a lost response. Use the Admin API to check the project and read its current keys. Authentication runs before JSON parsing. The checks run in this order: | Check | Failure | | --- | --- | | The Bearer token has the required format. | 401 `UNAUTHORIZED` | | Project access is ready. | 503 `PROJECT_STORE_UNAVAILABLE` | | The project ID is valid, the project exists, and the key matches. | 401 `UNAUTHORIZED` | | The key has the role required by the route. | 403 `FORBIDDEN` | | The project accepts new work. | 503 `PROJECT_UNAVAILABLE` | Database creation and deletion check the write role during auth. SQL queries accept either role at this stage; SQLite checks each statement's access later. KV also accepts either role during auth, then checks for writes after validating all operations. Every 401 includes `WWW-Authenticate: Bearer`. Wrong keys get the same response for every project state. A URL without the project ID segment has no route and returns 404. A read-only Project API key cannot create or delete databases, change data or schema, write to temporary tables, or run KV write operations. ## Data API The SQL endpoint runs statements against one existing SQLite database. It uses [project authentication](#project-authentication). Database creation, listing, and deletion use [database management](#database-management). ### Database identity ```json {"filename":"app.db"} ``` Use one nonempty filename. No extension is added. It must be well-formed Unicode. Names are not trimmed or repaired. Reject slashes, backslashes, colons, NUL, `.` and `..`. Suffixes `-wal`, `-shm`, and `-journal` are reserved in any case. File case and Unicode aliases follow the host filesystem. Use the same spelling in all requests. Two projects can each have `app.db`; these are separate databases. A Project API key cannot access another project's databases. A filename is a name, not a path or connection string. Text such as `?mode=ro` stays filename text. ### POST /api/db/v1/projects/{projectId}/query Run a batch in order. Use the same body for one statement or many. Success returns 200 after commit. Send `Content-Type: application/json` and no URL query parameters. The top-level object has exactly `filename` and `statements`. | Field | Type | Required | Rule | | --- | --- | --- | --- | | `filename` | string | Yes | Use a valid [database identity](#database-identity). | | `statements` | array | Yes | Send at least one statement. Array order is execution order. | | `statements[].sql` | string | Yes | Send exactly one SQL statement. Z8Data passes the original string to SQLite. | | `statements[].params` | array | No | Bind values to positional `?` placeholders in order. Omit to use `[]`. `null` is invalid. | Unknown fields in request or statement objects are rejected. Use positional `?` placeholders only. Named placeholders such as `:name`, `@name`, and `$name`, and numbered placeholders such as `?1`, are rejected. The number of values must match the placeholders. Placeholder-like text inside quoted strings or comments is not a binding. Field checks run after JSON parsing. Duplicate JSON property names are not detected; the last value for each name is used. Send each property once. Parameters bind values only. They cannot stand for table names, column names, SQL keywords, or a list of values. For `IN (?, ?, ?)`, send three separate values. Choose any dynamic identifiers from an allowed list in your client code. Validation checks the top-level object first, then the database identity, then statement input in array order. A body that is not an object or does not have exactly `filename` and `statements` returns `400 INVALID_REQUEST`. Once that shape check passes, invalid filename values return `400 INVALID_DATABASE_PATH`. A statements value that is not a nonempty array returns `400 INVALID_REQUEST`. These errors have a null `statementIndex`. An invalid item in the statements array returns `400 INVALID_REQUEST` with that item's zero-based index. SQL and values stay separate. Never join user values into SQL. Empty SQL, comment-only SQL, SQL containing NUL, and multiple statements in one item are invalid. A final semicolon and trailing comments are allowed. SQL is not trimmed, rewritten, truncated, or split on semicolons. Semicolons inside quoted text and trigger bodies are kept. Statements run in order. A later statement can use a table created earlier in the same batch. If SQL syntax, access rules, or the single-statement check fails, the whole batch is rolled back. This example needs an empty database. It creates a table, inserts a row, and reads that row in one request: ```bash curl -X POST 'https://z8data.com/api/db/v1/projects/abcdefghijkl/query' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"filename":"app.db","statements":[ {"sql":"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)"}, {"sql":"INSERT INTO users (name) VALUES (?) RETURNING id, name","params":["Alice"]}, {"sql":"SELECT id, name FROM users ORDER BY id"} ]}' ``` ```json {"results":[ {"columns":[],"rows":[],"changes":0}, {"columns":["id","name"],"rows":[[1,"Alice"]],"changes":1}, {"columns":["id","name"],"rows":[[1,"Alice"]]} ]} ``` A read-only request uses the same shape and the read-only Project API key: ```bash curl -X POST 'https://z8data.com/api/db/v1/projects/abcdefghijkl/query' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"filename":"app.db","statements":[{"sql":"SELECT id, name FROM users WHERE id = ?","params":[1]}]}' ``` ### Parameter and result values | Value | JSON form | Rule | | --- | --- | --- | | SQL NULL | `null` | Use JSON null. | | Text | string | Send JSON documents as strings when storing JSON data. | | Number | number | It must be finite. Whole-number parameters must be between -9007199254740991 and 9007199254740991, inclusive. | | Integer tag | `{ "type": "integer", "value": "9223372036854775807" }` | Send an exact signed 64-bit integer as decimal text. The range is -9223372036854775808 to 9223372036854775807. | | BLOB tag | `{ "type": "blob", "base64": "AQID" }` | Use canonical standard base64, with padding when needed. An empty string means an empty BLOB. | Send well-formed Unicode for exact SQL text. Lone UTF-16 surrogates are accepted but become U+FFFD, `"\ufffd"`, before SQLite reads or saves them. For example, `SELECT ?` with `params: ["\ud800"]` returns `"\ufffd"` without an error. Integer text uses decimal digits with an optional minus sign. Spaces, a plus sign, exponents, leading zeros, and `-0` are invalid. `0` is valid. Tagged integers within the safe number range are accepted too. BLOBs must use the standard alphabet, not URL-safe base64; whitespace, missing required padding, and noncanonical encodings are rejected. Tags have exactly the two fields shown above. Extra tag fields, malformed tags, booleans, arrays, other objects, non-finite numbers, and integers outside the allowed range are rejected. Use `0` or `1` for booleans. Parameters are validated before the batch starts. Send large integers as decimal strings inside the integer tag. Keep their exact digits when preparing the request. Encode bytes with the BLOB tag. Dates have no separate API type; choose a text or numeric format for your schema. ```json {"filename":"app.db","statements":[{ "sql":"SELECT ? AS large_integer, ? AS bytes, ? AS empty_bytes, ? AS missing", "params":[{"type":"integer","value":"9223372036854775807"},{"type":"blob","base64":"AQID"},{"type":"blob","base64":""},null] }]} ``` ```json {"results":[{ "columns":["large_integer","bytes","empty_bytes","missing"], "rows":[[{"type":"integer","value":"9223372036854775807"},{"type":"blob","base64":"AQID"},{"type":"blob","base64":""},null]] }]} ``` Results use JSON strings, finite numbers, null, and the same tags. Safe SQLite integers become JSON numbers; larger SQLite integers use the integer tag. SQLite REAL values stay JSON numbers, even when they are whole numbers outside the safe integer range. Such a result cannot be sent back as a number parameter under the input rules. BLOBs always use the BLOB tag. A value that cannot be encoded returns `UNSUPPORTED_RESULT_VALUE` and rolls back the batch. It is never silently replaced with null. ### Results and direct write counts | Field | Type | Meaning | | --- | --- | --- | | `results` | array | One result per statement, in request order. Returned only after the whole batch commits. | | `results[].columns` | array of strings | Column names in result order. Duplicate names are preserved. | | `results[].rows` | array of arrays | Every row is an array. Each cell matches the column at the same index. | | `results[].changes` | number or integer tag | Present for writes only. Counts directly changed rows, without trigger or foreign-key side effects. Large counts use the integer tag. | A read with no matching rows still has its column names and `rows: []`. Reads omit `changes`. An INSERT, UPDATE, or DELETE without RETURNING has empty columns and rows, plus its write count. With RETURNING, it has rows and a write count. Schema writes report zero changes. Setting application_id or user_version also reports zero changes. EXPLAIN and EXPLAIN QUERY PLAN for permitted SQL return rows and omit changes, even when explaining a write. Treat changes as optional; do not infer its presence from the first SQL keyword. ```json {"results":[ {"columns":["name"],"rows":[]}, {"columns":["value","value"],"rows":[[1,2]]}, {"columns":[],"rows":[],"changes":2} ]} ``` This example shows three independent result shapes. Rows are arrays so duplicate column names cannot overwrite each other. Use `RETURNING` when you need inserted IDs; there is no separate last-insert-ID response field. ### Transactions and rollback All statements run in order in one transaction against an existing database. Later statements see earlier changes in that batch. Concurrent batches using the read/write Project API key for the same database run one at a time, even for SELECT-only batches. Use the read-only Project API key for reads. The service does not promise an order for separate concurrent HTTP requests. Await one response before sending a request that depends on it. Commit happens only after every statement succeeds and the full result can be encoded as JSON. A 200 response means commit succeeded. A statement, deferred constraint, result encoding, or commit failure rolls back the full batch, including schema changes. There are no partial results. Statement errors have an index; begin and commit errors have a null index. Caller transaction commands are denied: BEGIN, COMMIT, END, ROLLBACK, SAVEPOINT, and RELEASE. A statement that cannot run inside the service transaction fails; it is never moved outside the transaction. For example, VACUUM cannot be used as a maintenance escape. Rollback finishes before a statement error is returned. If rollback itself fails, the response is INTERNAL_ERROR. Separate requests do not provide a stable SQL session. Keep temporary-table work and calls that depend on connection state, such as last_insert_rowid(), in the same batch. One batch targets one database. There is no transaction across database files or across HTTP requests. Run schema migrations as statement arrays without BEGIN or COMMIT. You can update PRAGMA user_version in the same batch as the schema changes. Do not retry write batches automatically. The API has no idempotency-key handling. If a connection drops before you receive a response, the batch may already have committed. Check the data before sending the write again. A busy response or queue timeout is not a partial success response. A generic 500 or INTERNAL_ERROR is not proof that a write did not happen. Distinguish an unknown outcome from a handled statement error whose batch was rolled back. ### PRAGMAs and SQL rules Use SQLite SQL. Use single quotes for SQL text and double quotes for identifiers. Double-quoted string literals are not allowed. Prefer bound parameters for values. Both roles may use these read PRAGMAs, with an optional argument where SQLite supports one. This list contains names, not executable SQL: ```text table_info, table_xinfo, table_list, index_info, index_xinfo, index_list, foreign_key_list, foreign_key_check, integrity_check, quick_check ``` Both roles may read these settings without an assignment: ```text application_id, user_version, schema_version, page_count, freelist_count, page_size, encoding, foreign_keys, synchronous, journal_mode, query_only, trusted_schema, busy_timeout, cache_size, auto_vacuum, compile_options, pragma_list, function_list, module_list ``` To inspect your schema, send a permitted PRAGMA such as `PRAGMA table_info(users)` through this query endpoint. Database get and list return file identities only. Names returned by pragma_list, function_list, or module_list describe the SQLite build; they do not grant permission to use blocked features. Only the read/write Project API key may assign `application_id` and `user_version`. These changes are part of the batch transaction. All other PRAGMAs are denied, including changes to connection settings. These examples each belong in a separate statement item: ```sql PRAGMA table_info(users); PRAGMA user_version; PRAGMA user_version = 2; -- read/write Project API key only ``` Foreign key constraints are enforced. Extension loading, ATTACH, DETACH, and the functions load_extension, readfile, writefile, and fts3_tokenizer are not allowed through the API. ### Limits and waiting The SQL Data API has no separate fixed HTTP body-size, SQL-length, or statement-count cap. The worker task budget below can still reject large requests. There is no result-row cap, automatic pagination, or streaming. All rows are returned in one JSON response. Use SQL `LIMIT` to reduce results. SQLite and HTTP infrastructure can still limit a request. KV rules are separate and described below. | Limit | Value | Failure | | --- | --- | --- | | Queued and running database tasks combined | 1,000 tasks | 500 `WORKER_QUEUE_FULL` | | Serialized queued and running database tasks combined | 10 MiB | 500 `WORKER_QUEUE_FULL` | | Wait for a database task to start | 5,000 ms | 500 `WORKER_QUEUE_TIMEOUT` | | SQLite lock wait, `busy_timeout` | 5,000 ms | A SQLite busy or locked error uses HTTP 503. | Data queries and database management share a fixed 10 MiB budget across queued and running tasks. Size is measured in UTF-8 bytes of each serialized internal task, including extra fields added by the server. This differs from the raw HTTP body size. A single task above 10 MiB always returns `500 WORKER_QUEUE_FULL`, even when the service is idle; waiting cannot make it fit. Smaller tasks can also return this error when other pending work uses the available capacity. Waiting too long to start returns `500 WORKER_QUEUE_TIMEOUT`. Do not split a batch automatically: that would change its transaction boundary. There is no SQL execution timeout or query-cancel endpoint. Aborting a fetch does not cancel an accepted batch or guarantee rollback. Queue and lock wait limits are not deadlines for the full HTTP request. ### HTTP, caching, and CORS Query success and handled errors are JSON with `Cache-Control: no-store`. The body must be JSON, regardless of the Content-Type header. Invalid or empty JSON returns 400 INVALID_REQUEST after authentication. The route accepts POST. OPTIONS returns 204 with no body and `Cache-Control: no-store`. It does not run SQL and needs no key. GET, HEAD, PUT, PATCH, and DELETE return 405 before authentication, without the database JSON error body or CORS headers. | CORS header | Value | | --- | --- | | `Access-Control-Allow-Origin` | `*` | | `Access-Control-Allow-Methods` | `POST, OPTIONS` | | `Access-Control-Allow-Headers` | `Content-Type, Authorization` | | `Access-Control-Max-Age` | `86400` | These CORS headers are included on query responses, including handled errors, and preflight responses. Cookie credentials are not enabled. Use a private server-side client when a key must stay hidden from browser users. ### Errors ```json {"error":{"code":"SQLITE_CONSTRAINT_UNIQUE","message":"SQLite constraint failed","statementIndex":1}} ``` SQL and database management operations share one [database error contract](#database-errors). Read that section for every HTTP status, error code, and statementIndex rule. A failed batch returns no partial results. ## Database management Use the same Project API keys as SQL. POST and DELETE need read/write access. GET and HEAD accept either role. Every success and handled error uses `Cache-Control: no-store`. HEAD has the same status and headers as GET, with no body. GET, HEAD, and DELETE reject all body bytes. OPTIONS is public and returns 204 without checking the project ID or key. CORS allows origin `*`, methods `GET, POST, DELETE, HEAD, OPTIONS`, headers `Content-Type, Authorization`, and a max age of 86400. Cookies do not authorize these routes. Unsupported methods return 405 before auth or project lookup. ### POST /api/db/v1/projects/{projectId}/databases Send exactly one JSON field, `filename`, and no query fields. Invalid JSON, missing fields, and unknown fields return 400 INVALID_REQUEST. The body must be JSON, regardless of the Content-Type header. ```bash curl -X POST 'https://z8data.com/api/db/v1/projects/abcdefghijkl/databases' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"filename":"app.db"}' ``` ```json {"database":{"filename":"app.db"}} ``` Success is 201 when the empty database is ready to use. An existing database or a filename that is already in use returns 409 DATABASE_EXISTS. Invalid filenames return 400 INVALID_DATABASE_PATH. Existing databases are never overwritten. Queries never create a missing database; they return 404 DATABASE_NOT_FOUND. Create your tables with SQL after creating the database. ### GET /api/db/v1/projects/{projectId}/databases Send no query fields to list this project's databases. ```bash curl 'https://z8data.com/api/db/v1/projects/abcdefghijkl/databases' -H 'Authorization: Bearer ' ``` ```json {"databases":[{"filename":"app.db"}]} ``` ```json {"databases":[]} ``` Results are sorted by filename using JavaScript string order, without locale sorting. Databases being created or deleted, or blocked by failed deletion, are omitted. The list also skips unsafe files, including symbolic links, hard links, and reserved sidecar names. There is no pagination or list-size cap. A listed database is not a guarantee that a SQL query will succeed. ### GET /api/db/v1/projects/{projectId}/databases?filename=app.db Use exactly one filename query field. Repeated and unknown fields return 400 INVALID_REQUEST. URL-encode the name once; a literal plus is `%2B`. JSON filenames are not URL-decoded. ```bash curl --get 'https://z8data.com/api/db/v1/projects/abcdefghijkl/databases' \ --data-urlencode 'filename=app.db' \ -H 'Authorization: Bearer ' ``` ```json {"database":{"filename":"app.db"}} ``` Success is 200. A missing database returns 404 DATABASE_NOT_FOUND. The response contains no schema, contents, key, or path. ### DELETE /api/db/v1/projects/{projectId}/databases?filename=app.db Use exactly one filename query field and zero body bytes. Whitespace and `{}` bodies are invalid. ```bash curl -X DELETE 'https://z8data.com/api/db/v1/projects/abcdefghijkl/databases?filename=app.db' \ -H 'Authorization: Bearer ' ``` ```json {"deleted":true,"database":{"filename":"app.db"}} ``` Success is 200 after accepted work finishes and the database is deleted. A missing database returns 404 DATABASE_NOT_FOUND. A database being created or deleted returns 503 DATABASE_UNAVAILABLE for conflicting operations. Failed deletion blocks the database; retry DELETE to finish deletion. A deleted filename can be created again inside the same project. A later DELETE targets that current database. Do not retry mutations automatically after a lost response. Check the database and data first. Absence from a list is not proof of deletion because blocked databases are hidden. ## Valkey KV API POST `/api/kv/v1/projects/{projectId}/query` and POST `/api/kv/v1/projects/{projectId}/keys` use [project authentication](#project-authentication). Each project has one KV namespace. A new project's namespace starts empty and needs no creation call or SQLite database. Both Project API keys can read and list keys. Writes need the read/write Project API key. Admin API keys and cookies grant no KV access. Valkey stores app KV data. The private SQLite System Store keeps project records and saved keys. The service checks Project API keys in RAM. App KV operations cannot read or change those records. ### KV request and validation For POST `/api/kv/v1/projects/{projectId}/query`, send a JSON object with exactly `operations`, a nonempty array. No URL query fields or extra body fields are allowed, including projectId, dir, and filename. Each operation accepts only its listed fields. Duplicate JSON properties use the last value, as in the current JSON parser. Send each property once. Both KV routes authenticate and reserve project access before JSON parsing. A missing, malformed, or replaced Project API key returns 401 UNAUTHORIZED. A Project API key for a different or deleted project returns the same error. Pending deletion with a matching key returns 503 PROJECT_UNAVAILABLE. Project access failure uses 503 PROJECT_STORE_UNAVAILABLE. There is no missing-namespace error for an active project. For `/query`, after auth, check the top object and array shape, then each operation in order. Check all input before checking writes for a read-only Project API key. A rejected batch sends no user operations to Valkey. Keys are nonempty, well-formed Unicode strings, without NUL, at most 512 UTF-8 bytes. Spaces, slashes, braces, glob characters, and `__proto__` are ordinary key text. Z8Data adds the authorized project's prefix to every key. Text that looks like a path or another project's prefix remains part of the user key. Clients cannot supply raw commands or change the project prefix. | `op` | Write? | Required fields besides `op` | Optional fields | | --- | --- | --- | --- | | `get` | No | `key` | None | | `set` | Yes | `key`, `value` | `ttl` | | `del` | Yes | `key` | None | | `exists` | No | `key` | None | | `ttl` | No | `key` | None | | `expire` | Yes | `key`, `ttl` | None | | `persist` | Yes | `key` | None | | `incr` | Yes | `key` | `by`, `ttl` | `ttl` is a positive safe integer JSON number of seconds from 1 through 2147483647. Strings, tags, zero, fractions, and negative values are invalid. `by` defaults to 1. It must be a safe integer JSON number; zero and negative values are allowed. Operation names are case-sensitive. Use the lowercase names in the table. [Key listing](#browse-kv-keys) uses the separate `/keys` route. There is no raw-command endpoint. The KV query route has no separate fixed HTTP body-size, operation-count, or value-size cap. It does not use the SQL worker task budget. Valkey and HTTP infrastructure can still limit requests. ### KV values and results A set value is null, a string, a finite number, an integer tag, or a BLOB tag from the [SQL value contract](#parameter-and-result-values). Whole numbers must be safe integers. Tags keep their type and exact content; keep an integer tag's `value` as a string to preserve every digit. Objects other than these two tags, arrays, and booleans are invalid. Apps can serialize other JSON as a string. A string that looks like JSON, such as `"10"` or `"null"`, stays a string. Stored null differs from a missing key. KV string values preserve lone UTF-16 surrogates through JSON storage. SQLite text replaces them with U+FFFD. KV keys still require well-formed Unicode. SET replaces the value. With ttl it sets a new expiry. Without ttl it clears any old expiry. EXPIRE changes the deadline of an existing key. PERSIST removes expiry. Expiry removes only the user key; it never removes project records or API keys. Deleting a SQLite database does not change KV data. ```bash curl -X POST 'https://z8data.com/api/kv/v1/projects/abcdefghijkl/query' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"operations":[{"op":"set","key":"session:1","value":"Alice","ttl":3600},{"op":"get","key":"session:1"}]}' ``` ```json {"results":[{"op":"set","ok":true},{"op":"get","found":true,"value":"Alice"}]} ``` Success is 200 with exactly one result per operation in the same order. Each result has only the fields shown below. Missing `get` results omit `value`. Missing `ttl` results omit `ttl`; a persistent key uses `ttl: null`. A key near expiry may have `ttl: 0`. Missing DEL, EXPIRE and PERSIST succeed with false. PERSIST on a key with no expiry also returns false. Each object below is one item in the response's `results` array. Values such as `"Alice"`, `60`, and `1` are examples. | Operation | Result example | Field rules | | --- | --- | --- | | `get`, key exists | `{ "op": "get", "found": true, "value": "Alice" }` | `value` is the stored KV value, including null or a tag. | | `get`, key is missing | `{ "op": "get", "found": false }` | The result has no `value` field. | | `set` | `{ "op": "set", "ok": true }` | `ok` is always true on success. | | `del` | `{ "op": "del", "deleted": true }` | `deleted` is a boolean. It is true when the operation removed a key. | | `exists` | `{ "op": "exists", "found": true }` | `found` is a boolean. It is true when the key exists. | | `ttl`, key exists | `{ "op": "ttl", "found": true, "ttl": 60 }` | `ttl` is a nonnegative integer number of seconds, or null for a key without expiry. | | `ttl`, key is missing | `{ "op": "ttl", "found": false }` | The result has no `ttl` field. | | `expire` | `{ "op": "expire", "updated": true }` | `updated` is a boolean. It is true when the operation set expiry on an existing key. | | `persist` | `{ "op": "persist", "updated": true }` | `updated` is a boolean. It is true when the operation removed expiry. | | `incr` | `{ "op": "incr", "value": 1 }` | `value` is the new counter value. It is a safe integer number or an integer tag. | ### Browse KV keys POST `/api/kv/v1/projects/{projectId}/keys` lists application key names without reading values or expiry. Use the read-only Project API key for listing and inspection. These requests do not create keys, change values, or reset expiry, and need no direct Valkey connection or credentials. An Admin API client can obtain the Project API key with GET `/api/admin/v1/project-keys?projectId={projectId}` using `Authorization: Bearer `. Use the response's `keys.readOnly` as the Project API key for KV requests. Keep both keys on the client's server. Send a JSON object with only these optional fields. `{}` starts an unfiltered listing. Null values, unknown fields, and URL query fields return 400 `INVALID_REQUEST` after auth. Duplicate JSON properties use the last value; send each property once. | Field | Type | Default | Rules | | --- | --- | --- | --- | | `cursor` | String | `"0"` | Canonical unsigned decimal text from `"0"` through `"18446744073709551615"`, without leading zeroes, signs, or whitespace. Start with `"0"`; continue with the returned cursor unchanged. Never convert it to a JavaScript number. | | `filter` | String | `""` | Case-sensitive literal substring of the application key name. Empty text matches every key. Well-formed Unicode, no NUL, at most 512 UTF-8 bytes. | | `count` | Integer JSON number | `100` | Scan work hint from 1 through 1000. It may change between continuation requests. | Filtering runs on the server across stored keys as the scan continues, including keys the client has not loaded. Matching does not trim spaces, normalize Unicode, or interpret glob patterns or regular expressions. `*`, `?`, brackets, backslashes, braces, slashes, and spaces match themselves. For example, `"user:*"` matches that literal substring. Use normal JSON escaping, such as `"\\"` for one backslash. Only application names are searched; prefix-like text stored by the application remains part of the name. ```bash curl -X POST 'https://z8data.com/api/kv/v1/projects/abcdefghijkl/keys' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"filter":"session:","count":100}' ``` Success is 200: ```json {"keys":["session:1","session:日本語 / {2}"],"cursor":"624","done":false} ``` | Response field | Type | Meaning | | --- | --- | --- | | `keys` | Array of strings | Matching application key names, preserved exactly, without internal namespace prefixes or value metadata. | | `cursor` | String | Continuation cursor. Treat it as an opaque string, not an offset or key name. | | `done` | Boolean | True exactly when the returned cursor is `"0"`. This is the completion signal, even if `keys` is empty. | To load more, send the same project and filter with the returned cursor: ```json {"cursor":"624","filter":"session:","count":100} ``` An intermediate response can be `{"keys":[],"cursor":"912","done":false}`. Continue from `"912"`. A final response can contain keys or be `{"keys":[],"cursor":"0","done":true}`. An active project with no stored keys succeeds with empty pages; it can still require continuation when other projects have keys in the shared store. Finish the scan before concluding that no keys match. Each request performs one scan step. There is no total count or guaranteed ordering. `count` is not an exact page size or maximum result count. Keys can repeat within or across responses; deduplicate by the exact name. Every request authenticates the selected project again, and filters or cursors cannot change its authorized namespace. The listing is not a snapshot. A complete scan returns matching keys that exist throughout it. Keys added, deleted, or expired during the scan may appear or be missed. See [Valkey SCAN semantics](https://valkey.io/commands/scan/). When refreshing, changing the project or filter, or recovering from a service interruption, discard accumulated results and start at `"0"`. Sending `"0"` always starts a new scan. Failures use [KV errors](#kv-errors), with `operationIndex: null`. Unavailable projects and service failures never become successful empty listings. Both routes follow the shared [KV HTTP rules](#kv-http-rules). ### Inspect and refresh KV keys Send `get` and `ttl` together to POST `/api/kv/v1/projects/{projectId}/query` with the read-only Project API key. Use the exact listed name in each `key` field, without trimming or URL-decoding it: ```bash curl -X POST 'https://z8data.com/api/kv/v1/projects/abcdefghijkl/query' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"operations":[{"op":"get","key":"session:1"},{"op":"ttl","key":"session:1"}]}' ``` ```json {"results":[{"op":"get","found":true,"value":"Alice"},{"op":"ttl","found":true,"ttl":60}]} ``` A key deleted or expired after listing returns HTTP 200 with `{"results":[{"op":"get","found":false},{"op":"ttl","found":false}]}`. Use `found` to distinguish absence from stored null, `""`, and `0`. Value types, integer and BLOB tags, and the distinction between no expiry, zero remaining seconds, and missing keys follow [KV values and results](#kv-values-and-results). To refresh the selected value and expiry, repeat this read batch. Refreshing the list alone does not refresh a previously fetched value. These read-only requests can be retried without changing values or expiry, but results may differ as keys change. Check HTTP status before reading success fields; a failed request is neither an empty list nor a missing key. ### Atomic KV counters INCR uses native INCRBY. A missing key starts at zero. A counter made by incr, or by set with a safe integer number, can be incremented. JSON strings, fractions, null, integer tags, and blob tags are not counter encodings. For example, setting the number 10 permits incr; setting the string "10" does not. A set integer tag stays a tag. Signed 64-bit overflow and wrong counter types fail with INTERNAL_ERROR and leave the counter value unchanged. Counters stay exact across the JavaScript safe integer limits. Safe results use a JSON number. Larger results use the integer tag, up to 9223372036854775807 or down to -9223372036854775808. GET returns the same exact value for a counter. It first decodes canonical decimal integer text. This is `0`, or an optional minus sign followed by a nonzero digit and zero or more digits. Out-of-range text is invalid stored data. Other stored text is decoded with the same value rules as SET. With incr.ttl, Z8Data puts INCRBY and EXPIRE NX together in the batch. The first increment starts expiry. Later increments keep that deadline, even if they supply a different ttl. A persistent counter gains expiry when ttl is supplied. Without ttl, incr preserves existing expiry and creates missing counters without expiry. There is one result for incr, including when it has ttl. Both command replies are checked. If INCRBY fails at runtime, EXPIRE NX can still set expiry on the existing non-counter key. The API returns one error with the incr operation's index. There is no rollback of this expiry change. An external app can use this general counter for its own fixed-window rate-limit policy: ```json {"operations":[{"op":"incr","key":"rate:user:123","ttl":60}]} ``` The external app compares the returned count with its own limit and decides whether to allow its own work. Z8Data returns a counter; it has no limiter, policy, allow/deny result, or automatic counter-based 429. Each successful increment counts even if the external app rejects the work. The window does not slide. After expiry, the next increment returns one. The external app owns failure handling. A KV error is not a zero count or an allow/deny decision. Do not automatically retry an uncertain increment or use a local fallback counter: the first increment may already have run. A crash can lose recent increments within the AOF window. There is no exact-once counting or strict rolling-window promise. ### KV batches and failures The complete batch runs in one MULTI/EXEC. Later operations see earlier writes. Other clients cannot run commands in the middle. Queue-time errors abort the batch. Runtime command errors do not undo earlier writes or stop later commands. Z8Data checks every reply and returns the first known failed operation, with no partial success results. See [Valkey transactions](https://valkey.io/topics/transactions/). A connection loss or timeout after EXEC may leave the write outcome unknown. The API returns VALKEY_UNAVAILABLE with operationIndex: null. Failed HTTP operations are never replayed automatically. A manual retry can repeat an increment, replace a value, or reset a TTL. ### KV errors Every handled KV error has exactly `{ error: { code, message, operationIndex } }`. `code` and `message` are strings. `operationIndex` is a nonnegative integer or null. Top-level input, auth, project-state, and connection errors have a null index. Operation input errors, the first forbidden write, stored-data failures and known command failures have the operation's zero-based index. The two commands for incr.ttl both map to that incr index. Unknown queue failure indexes stay null. SQL keeps statementIndex; Admin project and key errors have no index. For `/keys`, `operationIndex` is always null. Invalid JSON, field values, unknown fields, and URL query fields return `INVALID_REQUEST` after auth. Auth and project-state failures follow [project authentication](#project-authentication); unavailable projects and backend failures never return successful empty listings. | Code | HTTP | Fixed message | | --- | --- | --- | | INVALID_REQUEST | 400 | Invalid request | | UNAUTHORIZED | 401 | Invalid API key | | FORBIDDEN | 403 | API key does not allow this operation | | PROJECT_UNAVAILABLE | 503 | Project is being changed or deleted | | PROJECT_STORE_UNAVAILABLE | 503 | Project access is unavailable | | VALKEY_UNAVAILABLE | 500 | Could not reach the key-value store | | VALKEY_MEMORY_FULL | 507 | Key-value store memory limit reached | | INTERNAL_ERROR | 500 | Could not complete the key-value operation | OOM includes a queue-time transaction abort. Other server command errors, malformed listing replies, and invalid stored values use INTERNAL_ERROR, not a connection error. Error messages never include server reply text, keys, values, credentials, or the Valkey URL. Example connection failure on either route, HTTP 500: ```json {"error":{"code":"VALKEY_UNAVAILABLE","message":"Could not reach the key-value store","operationIndex":null}} ``` ### KV HTTP rules Send `Content-Type: application/json`. The body must be JSON regardless of the Content-Type header. Empty or invalid JSON returns 400 `INVALID_REQUEST` after auth. For both `/query` and `/keys`, POST success and handled errors use JSON, `Cache-Control: no-store`, and the [SQL query CORS headers](#http-caching-and-cors). OPTIONS is public 204 with no-store and these CORS headers. Unsupported methods, including GET and HEAD, return an empty 405 response before auth, without CORS headers. Each 401 includes `WWW-Authenticate: Bearer`. ### KV availability and project cleanup The client connects only for valid KV work or configured project cleanup. `VALKEY_URL` accepts `redis://` or `rediss://` with a hostname. Its path must be empty, `/`, or `/0`. Query strings, fragments, and other database numbers are invalid. A missing or invalid URL returns `VALKEY_UNAVAILABLE` for valid KV work. A missing or invalid `VALKEY_URL`, connection failure, or timeout does not disable SQLite, the System Store, auth, project creation, rename, key reads or replacement, Admin auth, public pages, health, or telemetry. Connections have a five-second deadline, then each command or complete MULTI/EXEC exchange has a five-second deadline. Unsent timed-out work is cancelled by closing its connection. Sent writes may still have run. New requests may reconnect. Key replacement, project deletion, and shutdown wait for accepted KV work, even if its HTTP client disconnects. Rename and key changes keep KV values. Project deletion saves `deleting`, then removes that project's KV keys before SQL file cleanup. Cleanup failure leaves `deleting` and returns 500 `INTERNAL_ERROR` in the [Admin project error format](#admin-errors). A later DELETE retries cleanup, also after restart. Other projects stay available. With an empty `VALKEY_URL`, project deletion skips KV cleanup. A nonempty but invalid URL makes cleanup fail. Finish required cleanup before removing Valkey configuration after use. Compose keeps Valkey private and persists its AOF on valkey_data. Its shared maxmemory defaults to 256mb with noeviction. Leave extra process RAM for the OS, Node, SQLite, buffers, and background persistence. AOF with appendfsync everysec can lose about one second of recent writes after a crash; an acknowledged write is not necessarily fsynced. AOF is not a backup. See [Valkey persistence](https://valkey.io/topics/persistence/). ## File Storage API Store attachments as raw bytes on the app volume. Each project has a private `files.db` catalog that uses the same SQLite workers and eight-connection budget as application databases. File streaming and SHA-256 hashing run outside those workers. SQL cannot access the catalog. Run one app process per volume. The base path is `/api/files/v1/projects/{projectId}/files`. Generate a lowercase UUIDv4 `fileId` before upload. Use a new ID for every replacement. Original names are metadata; disk paths are never returned. | Method and suffix | Authorization | Success | | --- | --- | --- | | PUT `/{fileId}/content` | Read/write Project API key or upload grant | 201 `{ file }` after durable publication | | GET base path | Either Project API key | 200 `{ files, nextCursor }` | | GET `/{fileId}` | Either Project API key | 200 `{ file }` | | GET `/{fileId}/content` | Either Project API key or download grant | 200 full binary content | | HEAD `/{fileId}/content` | Either Project API key or download grant | Same representation headers, no body | | DELETE `/{fileId}` | Read/write Project API key | 200 `{ deleted: true, fileId }` after complete removal | | POST `/{fileId}/grants` | Either key for download; read/write for upload | 201 `{ url, method, headers, expiresAt }` | The Admin API key cannot access file data or issue grants. Applications check end-user permissions before issuing grants. Keep Project API keys on your application server. A grant authorizes one operation on one file generation; it cannot list files, inspect metadata, delete, or issue another grant. ### Upload files PUT sends raw bytes with `Content-Type: application/octet-stream`. Content encodings are rejected. Bearer uploads require `X-Z8Data-Filename` with percent-encoded UTF-8, `X-Z8Data-Content-Type` with the declared media type, and `X-Z8Data-Size` with the exact decimal byte count. Empty files are allowed. The declared type is metadata, not content validation. Filenames may contain at most 255 UTF-8 bytes and cannot be empty, contain controls, or contain invalid Unicode. Media types and their parameters must be valid ASCII, at most 255 bytes. `Content-Length` is optional but must match the declared size when present. Actual short or excess bodies fail. HTTP chunked transfer works; resumable uploads do not. Existing IDs cannot be overwritten or appended to. ```sh curl --request PUT 'https://z8data.com/api/files/v1/projects/abcdefghijkl/files/6c1d26cc-f5f7-46a5-b69e-564edb0bba2e/content' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/octet-stream' \ --header 'X-Z8Data-Filename: hello.txt' \ --header 'X-Z8Data-Content-Type: text/plain' \ --header 'X-Z8Data-Size: 5' \ --data-binary 'hello' ``` Publication streams and hashes the content, verifies the size, syncs and closes the file, renames it, syncs the directory, and commits ready metadata before returning 201. A failed metadata request does not by itself disable the project's File Storage API. Upload cleanup checks the current metadata and preserves ready files. If that lookup fails, cleanup is deferred. Dispatched mutations are never replayed automatically. If an upload response is lost, inspect its known ID. `ready` confirms publication; `pending` or `uploading` means unresolved work. A 404 only says no row currently exists. Confirm the original request has settled before starting another upload. Other files remain usable. Do not automatically retry an ambiguous PUT. There is no deleted-file history or exactly-once guarantee. ### Signed transfer URLs POST the following JSON to `/{fileId}/grants` with the read/write Project API key to create an upload grant: ```json { "operation": "upload", "filename": "hello.txt", "contentType": "text/plain", "size": 5 } ``` For a ready file, either Project API key can issue a download grant with exactly `{ "operation": "download" }`. Grant JSON is limited to 8 KiB. Upload grants return method `PUT` and the octet-stream header. Download grants return `GET` and empty headers; they also allow HEAD. `url` is relative and contains a `grant` query parameter. Resolve it against your configured Z8Data origin. `expiresAt` is UTC ISO text. The default lifetime is 15 minutes, configurable up to one hour. A signed upload takes its filename, type, and size from the grant; metadata headers that override them are rejected. It claims an existing pending file record once. Reissuing an identical unexpired pending upload grant preserves that record and its original expiry. Download grants may be used repeatedly until expiry or revocation. Expiry controls transfer admission; a transfer already accepted can finish within its deadlines. Replacing a Project API key revokes its grants; rotating both keys revokes both. Grants survive restart when their key remains current. Deleting a row invalidates its generation. Even accidental ID reuse cannot make old grants valid again. Pending records for revoked upload grants remain until explicitly cancelled with DELETE or expired by maintenance. Example browser transfer after your application server has authorized the user and returned an upload grant: ```js const response = await fetch(new URL(grant.url, z8DataOrigin), { method: grant.method, headers: grant.headers, body: selectedFile, }); if (!response.ok) throw new Error(`Upload failed: ${response.status}`); const { file } = await response.json(); // Send file.id to your application server to attach it to a record. ``` Do not combine Bearer and grant authorization. Grant tokens are limited to 4 KiB. Only content routes accept `grant`. Keep signed URLs, keys, filenames, and content out of logs and analytics. The supplied Caddy configuration filters request objects, headers, and URL fields from default proxy error logs and leaves access logging disabled. ### File metadata and deletion A file response contains `id`, `projectId`, `state`, `filename`, `contentType`, `expectedSize`, `size`, `sha256`, `createdAt`, and `completedAt`. Timestamps are UTC ISO strings. `size`, `sha256`, and `completedAt` are null until publication; checksums are lowercase SHA-256 hex. States are `pending`, `uploading`, `ready`, and `deleting`. List returns ready files ordered by `(createdAt, id)` ascending. The optional `limit` defaults to 100 and has a maximum of 1000. Pass `nextCursor` back as the optional `cursor` until it is null. The cursor grants no access. Listings are not a snapshot when other requests change files. Unknown or duplicate query parameters are rejected. DELETE cancels a pending grant or removes a file. It returns `FILE_BUSY` while an admitted upload is unsettled. Deletion first blocks new content reads and waits up to five seconds for existing downloads. If cleanup remains, it returns `FILES_BUSY`; repeat DELETE to finish it. Success means the bytes and row are gone. Later metadata GET and DELETE return 404. Unfinished cleanup retries through maintenance, explicit DELETE, or restart. Store attachment references by uploading, confirming `ready`, and saving the ID in application SQL. Obtain downloads only after checking the user's access. Explicitly delete confirmed unreferenced attachments. SQL changes and file changes are separate transactions; deleting a SQL record does not delete an attachment. ### File limits and HTTP behavior Files have no configured size, project-byte, or file-count quotas. Uploads must still match their declared size exactly; the declared size must be a nonnegative safe integer. Pending grants do not consume transfer slots. Concurrent requests and transfer time remain bounded by the settings below. | Environment setting | Default | | --- | --- | | `FILES_SIGNED_URL_TTL_SECONDS` | 900; valid from 1 to 3600 | | `FILES_MAX_UPLOADS` / `FILES_PROJECT_MAX_UPLOADS` | 8 / 2 | | `FILES_MAX_DOWNLOADS` / `FILES_PROJECT_MAX_DOWNLOADS` | 16 / 4 | | `FILES_MAX_METADATA_REQUESTS` / `FILES_PROJECT_MAX_METADATA_REQUESTS` | 64 / 16 | | `FILES_TRANSFER_IDLE_TIMEOUT_MS` | 30000 | | `FILES_TRANSFER_TIMEOUT_MS` | 600000 | Settings must be positive safe integers with project concurrency limits no greater than global limits and idle timeout no greater than total timeout. Common limits apply to all projects. Requests exceeding concurrent capacity fail immediately. Metadata operations, grants, DELETE, and content HEAD have a twenty-second total deadline; grant body reading has a ten-second deadline. Dispatched catalog tasks have a ten-second execution deadline, followed by confirmed worker exit when needed. SQL execution behavior is unchanged. Catalog tasks share SQL's 1,000 pending tasks, 10 MiB payload budget, and five-second queue-start deadline. Disk space is not reserved in advance. An actual disk-full error during writes, syncs, metadata changes, or cleanup returns `FILE_STORAGE_FULL`. Downloads always use `application/octet-stream`, safe attachment disposition, `nosniff`, a sandbox CSP, `no-referrer`, and `no-store`. Range and conditional headers are ignored: an authorized request gets the whole file with 200, no ETag, no 206 or 304, and `Accept-Ranges: none`. Files are transfer URLs, not inline document hosting. Known routes provide unauthenticated OPTIONS, wildcard CORS without cookies, and route-specific allowed methods. CORS exposes `Content-Length` and `Content-Disposition`. All Files responses, including errors and grants, use `no-store`. Unsupported methods return 405 with `Allow`. GET, HEAD, and DELETE reject nonempty bodies. Duplicate auth or upload metadata headers and unknown fields are rejected. After a binary response has started, a failed stream closes instead of appending JSON. ### File errors and recovery Files errors use `{ "error": { "code": "FILE_NOT_FOUND", "message": "File not found" } }`, without a statement or operation index. Existing project auth errors retain their precedence. Missing or invalid credentials fail before body parsing. Malformed authenticated grant JSON returns 400; a valid upload-grant request with a read-only Project API key returns 403. | Status | File codes | | --- | --- | | 400 | `INVALID_REQUEST` | | 404 | `FILE_NOT_FOUND` | | 408 | `TRANSFER_TIMEOUT` | | 409 | `FILE_ID_USED`, `FILE_NOT_READY`, `FILE_BUSY` | | 415 | `UNSUPPORTED_MEDIA_TYPE` | | 507 | `FILE_STORAGE_FULL` | | 503 | `FILES_BUSY`, `FILE_STORE_UNAVAILABLE`, `FILE_UNAVAILABLE` | | 500 | Sanitized `INTERNAL_ERROR` | Startup recovery validates the catalog schema, project identity, integrity, and file records before admitting Files requests or deleting unpublished bytes. It preserves ready files and unexpired pending grants, removes interrupted uploads, finishes deletion, and removes validated orphan content. A missing or wrong-sized ready file keeps its metadata and returns `FILE_UNAVAILABLE`. A missing or corrupt catalog is never silently reset. An unused project can list or return not-found without creating storage. SQL and KV remain separate from a project's Files availability. Maintenance expires pending grants and retries unfinished upload/deletion cleanup in bounded batches about once per minute. It checks each file's current state before removing content. Failed startup recovery and maintenance steps retry with backoff from one to thirty seconds. Failed metadata requests and deferred cleanup do not require an app restart. Actual catalog corruption or unsafe storage paths can still make File Storage unavailable. Whole-project Admin deletion may remove a corrupt catalog after all native holders have closed; a failed project deletion remains retryable through Admin DELETE. Shutdown stops new Files requests and maintenance. Accepted requests have at most the smaller of ten seconds or one third of `SHUTDOWN_TIMEOUT_MS` to finish before cancellation. Cleanup retains the shared pool until requests settle or durable recovery state remains. The default process deadline is thirty seconds, app Compose grace is forty-five seconds, and Caddy grace is fifteen seconds. See [backup and restore](#backup-and-restore). ## Backup and restore Stop app access and stop the app before copying the entire `app_data` volume. Include `system/projects.db`, project SQL databases, private `files.db` catalogs, content directories, and any remaining SQLite sidecars. Do not copy only `files/` or only the main database files. Protect the backup because the System Store contains project credentials. Keep the matching configuration and record volume ownership. Stop Valkey separately and copy the complete `valkey_data` volume while it is stopped, including its AOF manifest and segments. Keep it with the app backup when your application needs SQL, files, and KV from the same stopped period. These procedures require downtime; copying live files is not a supported backup procedure. Restore into stopped services with the original ownership and matching configuration. The supplied app container runs as UID/GID 1001 and stores data under `/var/lib/app`. Start the app with public access blocked and allow Files recovery to finish before reopening traffic. Restoring old Project API keys can restore still-unexpired grants; rotate keys before reopening if necessary. Verify application records, representative file hashes, and KV expiry behavior before accepting traffic. Durability relies on the host filesystem honoring file and directory syncs. Automated process-crash tests do not prove survival of host power loss, failed disks, or broken storage hardware. Browser/Caddy transfers, container stop, whole-service forced restart, stopped-volume restore, and large-file RSS measurements are separate human-run operational checks. ## Admin API Every Admin API request except OPTIONS uses `Authorization: Bearer `. Project API keys do not grant access. The scheme is case-insensitive; the token is exact. Missing, wrong, malformed, or duplicate Authorization headers return 401 with `WWW-Authenticate: Bearer` and `{ "error": "Invalid or missing Admin API key." }`. If Admin API authentication is unavailable, requests return 503. Auth runs before body parsing. Cookies do not grant access. Set the Admin API key with `Z8DATA_ADMIN_API_KEY`. It must contain one or more ASCII letters, digits, `.`, `_`, `~`, `+`, `/`, or `-`, with optional `=` characters at the end. It need not be a UUID. Use one space between Bearer and the key. Restart the app after changing the configured key. All routes below start with `/api/admin/v1`. GET, HEAD, and DELETE must have no body. POST and PATCH accept JSON bodies up to 4096 bytes, measured as encoded UTF-8 bytes, not characters. Send `Content-Type: application/json`; the body must be JSON regardless of the Content-Type header. POST and PATCH accept no query fields. Only the listed fields are allowed. Unknown or repeated query fields are rejected. Duplicate JSON property names use the last value; send each property once. HEAD returns the GET status and headers without a body. Unsupported methods return 405 with `Allow` and no body after auth. Every route supports public OPTIONS preflight with status 204, even when Admin auth is unavailable. It returns `Allow` with that route's methods, including OPTIONS. CORS permits any origin with `Content-Type` and `Authorization` headers. It exposes `WWW-Authenticate` and `Allow`, and sets `Access-Control-Max-Age: 86400`. Cookie credentials are not enabled. All responses use `Cache-Control: no-store`. Use HTTPS for remote servers. Do not place the Admin API key in public JavaScript or URLs. ### Admin routes | Method | Route | Query or JSON body | Success | | --- | --- | --- | --- | | GET, HEAD | `/project-store` | None | 200 `{ status }` | | GET, HEAD | `/projects` | None | 200 `{ projects }` with an array of project objects | | GET, HEAD | `/projects` | `projectId` query | 200 `{ project }` | | POST | `/projects` | `{ "name": "My app" }` | 201 `{ project, keys }` | | PATCH | `/projects` | `{ "projectId": "abcdefghijkl", "name": "New name" }` | 200 `{ project }` | | DELETE | `/projects` | `projectId` query | 200 `{ deleted: true, projectId }` | | GET, HEAD | `/project-keys` | `projectId` query | 200 `{ projectId, keys }` | | POST | `/project-keys` | `{ "projectId": "abcdefghijkl" }` | 201 `{ projectId, keys }` | | POST | `/project-keys` | `{ "projectId": "abcdefghijkl", "role": "read-only" }` | 201 `{ projectId, role, key }` | | POST | `/project-keys` | `{ "projectId": "abcdefghijkl", "role": "read-write" }` | 201 `{ projectId, role, key }` | | GET, HEAD | `/databases` | `projectId` query | 200 `{ databases: [{ filename }] }` | | GET, HEAD | `/databases` | `projectId` and `filename` query | 200 `{ database: { filename } }` | | POST | `/databases` | `{ "projectId": "abcdefghijkl", "filename": "app.db" }` | 201 `{ database: { filename } }` | | DELETE | `/databases` | `projectId` and `filename` query | 200 `{ deleted: true, database: { filename } }` | | GET, HEAD | `/telemetry` | None | 200 `{ telemetry }` | ### Project and key response fields Each project object has exactly these fields: | Field | JSON type | Meaning | | --- | --- | --- | | `id` | string | The project ID, exactly 12 lowercase letters or digits. | | `name` | string | The saved project name. | | `status` | string | Either `"active"` or `"deleting"`. | | `createdAt` | string | The creation time in ISO 8601 format. | | `updatedAt` | string | The last project update time in ISO 8601 format. | A `keys` object has exactly `readOnly` and `readWrite`. Both values are Project API key strings. A response for replacing one role has `projectId`, `role`, and `key` instead. All three are strings. `role` is `"read-only"` or `"read-write"`, and `key` is the new key for that role. Dates are ISO 8601 strings. Project list and detail responses never contain keys. Names must contain 1 to 100 Unicode code points, be valid Unicode, and have no NUL. The service saves names as supplied, including spaces. Names need not be unique. Save and use the returned `id`. Project creation returns 201 with both keys: ```json { "project": { "id": "abcdefghijkl", "name": "My app", "status": "active", "createdAt": "2026-09-14T12:00:00.000Z", "updatedAt": "2026-09-14T12:00:00.000Z" }, "keys": { "readOnly": "", "readWrite": "" } } ``` The key strings above are placeholders. The service returns full UUIDv4 secrets. ### Project lifecycle GET `/projects` returns all active and deleting projects, sorted by `createdAt` ascending, then `id` ascending for equal timestamps. An empty list is `{ "projects": [] }`. There is no pagination or list-size cap. Renaming updates the name and `updatedAt`; it keeps the project ID, keys, databases, and KV data. GET `/project-store` returns `{ "status": "ready" }` or `{ "status": "unavailable" }`. When project access is unavailable, project store status, telemetry, and public health remain available. Other Admin API actions and project data requests fail with 503 `PROJECT_STORE_UNAVAILABLE` once earlier method, auth, and input checks pass. A malformed Project API key sent as the Bearer token still returns 401. This route does not create or reset the System Store. The service loads it at startup. It creates the store only if no System Store file exists and project storage has no entries. POST `/project-keys` without `role` replaces both keys. With `role`, it replaces only `read-only` or `read-write`; the other role's key keeps working after success. Other role values, including null, return 400 `INVALID_REQUEST`. After success, the replaced keys stop working. Do not retry automatically after a lost response. Read the current keys to check the result. Key replacement and project deletion wait for accepted SQL, KV, Files, and Admin database requests for that project to finish. While waiting, new SQL, KV, and database management requests return 503 `PROJECT_UNAVAILABLE` when auth passes. Database creation and deletion still check the key's write role first. Other projects can keep using their data and changing their Admin settings while a project drains. Work within one project stays ordered. A thirty-second drain timeout returns PROJECT_BUSY without changing keys or project status. DELETE `/projects` removes the project, all its databases and files, and access through its keys. It also removes the project's KV data when Valkey is configured. See [KV cleanup](#kv-availability-and-project-cleanup). A failed deletion can leave a `deleting` project; retry deletion to finish cleanup. A deleting project can still be listed and read with GET `/projects`, but reading or replacing its keys, renaming it, and accessing its databases return 503 `PROJECT_UNAVAILABLE` with valid Admin auth. After deletion finishes, Admin lookup and another DELETE return 404 `PROJECT_NOT_FOUND`; old Project API keys return 401 `UNAUTHORIZED` on SQL and KV requests. The service keeps an internal deleted record so it will not reuse the project ID. ### Admin errors Project operation errors use `{ error: { code, message } }`. Both fields are strings. Database management errors use the [database error format](#database-errors), with `statementIndex: null`. Invalid input, including a malformed project ID, returns 400 `INVALID_REQUEST`; a valid but unknown or deleted project ID returns 404 `PROJECT_NOT_FOUND`. The database rules for filenames, conflicting operations, and retries also apply to Admin API database actions. Authentication errors use an `error` string. Missing or invalid Admin API key configuration returns 503 with `{ "error": "Set Z8DATA_ADMIN_API_KEY in .env and restart the app." }`. This differs from `PROJECT_STORE_UNAVAILABLE`, which means Admin auth succeeded but project access is unavailable. After auth and method checks, POST and PATCH bodies over 4096 bytes return 413. Parser failures can return 400. Their JSON bodies depend on the route: | Route | Parser error or oversized-body response | | --- | --- | | `/projects`, `/project-keys` | `{ "error": { "code": "INVALID_REQUEST", "message": "Invalid request" } }` | | `/databases` | `{ "error": { "code": "INVALID_REQUEST", "message": "Invalid request", "statementIndex": null } }` | DELETE also passes through the body-size parser: a body over the limit returns 413 using the route's error shape above; a smaller nonempty body returns 400 `INVALID_REQUEST`. GET and HEAD reject body bytes with 400 `INVALID_REQUEST`; HEAD always omits the response body. For 413, reduce the request size before sending it again. Do not assume `INVALID_REQUEST` always has HTTP status 400. 503 `PROJECT_BUSY` means the project operation queue is full, the operation waited too long to start, key replacement or deletion waited too long for accepted work to finish, or another deletion of the same project is pending. The queue allows 100 waiting operations and a five-second wait to start. Key replacement and deletion can wait up to 30 seconds for accepted work to finish. Project creation can also return `PROJECT_BUSY` if the service cannot allocate an unused project ID. Database task capacity uses `WORKER_QUEUE_FULL` and `WORKER_QUEUE_TIMEOUT`. Check the current project and keys before retrying a mutation whose response was lost. A generic 500 or `PROJECT_STORE_UNAVAILABLE` does not prove that the mutation was rolled back. An Admin project `INTERNAL_ERROR` uses the fixed message `Could not complete the project operation`. ### Admin request examples ```bash curl 'https://z8data.com/api/admin/v1/projects' \ -H 'Authorization: Bearer ' curl 'https://z8data.com/api/admin/v1/projects' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"name":"My app"}' curl 'https://z8data.com/api/admin/v1/databases?projectId=abcdefghijkl' \ -H 'Authorization: Bearer ' ``` ## Telemetry API ### GET /api/admin/v1/telemetry Send `Authorization: Bearer `. Project API keys and cookies cannot read telemetry. No query fields or body are allowed. Success returns 200 with the JSON shape below. HEAD returns the same status and headers without a body. The [Admin API](#admin-api) authentication, OPTIONS, CORS, and caching rules apply here too. Invalid input uses the [database error format](#database-errors) with `400 INVALID_REQUEST` and `statementIndex: null`. ```bash curl 'https://z8data.com/api/admin/v1/telemetry' \ -H 'Authorization: Bearer ' ``` Example response: ```json { "telemetry": { "sampledAt": "2026-09-11T12:00:00.000Z", "host": { "cpuCount": 4, "cpuPercent": 12.5, "memory": { "totalBytes": 8589934592, "usedPercent": 40 }, "disk": { "totalBytes": 107374182400, "usedPercent": 25 } }, "database": { "queuedTasks": 0, "totalQueueWaitMs": 15, "startedTasks": 10, "limits": { "pendingTasks": 1000, "taskWaitMs": 5000 } }, "files": { "uploads": 0, "downloads": 0, "metadata": 0, "failures": 0, "cleaned": 0, "recoveringProjects": 0, "unavailableProjects": 0 }, "traffic": { "totals": { "incomingRequests": 12, "incomingKvRequests": 5 } } } } ``` Samples may be reused for 250 ms after collection finishes. Concurrent reads share one sample. `sampledAt` is an ISO 8601 timestamp taken when collection starts. Responses do not include keys or filesystem paths. Sampling failure returns 500 with `{ "error": "Failed to read telemetry" }`. | Host field | Meaning | | --- | --- | | `cpuCount` | Available parallelism reported by Node.js. | | `cpuPercent` | CPU use between readings, on a scale of 0 to 100. The first reading starts when the collector is first used. | | `memory.totalBytes` | Total host memory reported by the OS. | | `memory.usedPercent` | Host memory use, calculated from total and free memory. | | `disk.totalBytes` | Total size of the filesystem that contains the data directory. It is null when unavailable. | | `disk.usedPercent` | Used space on that filesystem. It is null when unavailable. | Database counters measure tasks, not HTTP requests. One request can need more than one task. `queuedTasks` is the number waiting to start. `totalQueueWaitMs` is the total wait time for tasks that have started, and `startedTasks` counts those tasks. `limits.pendingTasks` is the maximum number of queued and running tasks together. `limits.taskWaitMs` is the queue wait limit, not a SQL execution deadline. `traffic.totals.incomingRequests` counts POST attempts that reach `/api/db/v1/projects/{projectId}/query`, including failed auth and invalid requests. It does not count database management, Admin API, or health requests. `traffic.totals.incomingKvRequests` counts POST attempts that reach `/api/kv/v1/projects/{projectId}/query` or `/api/kv/v1/projects/{projectId}/keys`, including failed auth, invalid JSON, and invalid input. GET, HEAD, OPTIONS, and unmatched URLs do not increase either counter. KV does not change SQL traffic or database queue fields. Health and telemetry never ping Valkey. Cumulative counters reset when the service restarts. The fixed-size `files` counters report active upload/download/metadata requests, failed admitted requests or maintenance attempts, completed cleanup operations, recovering projects, and quarantined projects. They come from service state without scanning catalogs. Database queue counters also include private Files metadata tasks. ## Health endpoint ### GET /api/health Checks whether the service can answer HTTP requests. No API key is required. ```bash curl 'https://z8data.com/api/health' ``` ```json {"status":"ok"} ``` GET returns 200 JSON with `Cache-Control: no-store`. HEAD returns the same status and headers without a body. OPTIONS returns 204 with no body and `Allow: GET, HEAD, OPTIONS`. Other methods return an empty 405 response. Health responses have no CORS headers. Query fields are ignored. A successful health response does not guarantee that authentication, SQL, KV, or telemetry will succeed. ## Database errors ```json {"error":{"code":"DATABASE_NOT_FOUND","message":"Database not found","statementIndex":null}} ``` Handled SQL and database management errors have exactly these three fields inside `error`. `code` and `message` are strings. `statementIndex` is a nonnegative integer or null. Use `code` to handle an error. Messages use fixed text and omit SQL, keys, parameter values, stack traces, and server file paths. There are no partial results. Framework and proxy failures can have a different body. Check the HTTP status and response shape before reading results or error fields. `statementIndex` starts at zero. It identifies the failed statement, including invalid statement input. It is `null` for request, auth, path, queue, database open, transaction begin or commit, and database management errors. A rollback failure also returns a null index with `INTERNAL_ERROR`. | Code | HTTP | Meaning | | --- | --- | --- | | `INVALID_REQUEST` | 400 | Invalid JSON, request fields, statement input, parameters, or more than one SQL statement in an item. Admin body-size failures can use 413. | | `INVALID_DATABASE_PATH` | 400 | Invalid database identity or an unsafe file path. | | `UNSUPPORTED_RESULT_VALUE` | 400 | A result cannot use the supported JSON value formats. The batch is rolled back. | | `SQLITE_ERROR*`, `SQLITE_ABORT*`, `SQLITE_RANGE`, `SQLITE_MISMATCH`, `SQLITE_TOOBIG` | 400 | SQLite could not run the caller's statement. | | `UNAUTHORIZED` | 401 | The key is missing or invalid. The response includes `WWW-Authenticate: Bearer`. | | `FORBIDDEN` | 403 | A valid key has the wrong API role. | | `SQLITE_AUTH*`, `SQLITE_READONLY*` | 403 | SQLite denied SQL under the key or connection rules. | | `DATABASE_NOT_FOUND` | 404 | The requested database does not exist. | | `DATABASE_EXISTS` | 409 | The database or a SQLite sidecar already exists, the filename is in use, or another create is pending for the same identity. | | `SQLITE_CONSTRAINT*` | 409 | A constraint failed, such as UNIQUE, NOT NULL, CHECK, or a foreign key. | | `SQLITE_BUSY*`, `SQLITE_LOCKED*` | 503 | SQLite database access is busy. | | `DATABASE_UNAVAILABLE` | 503 | Creation, deletion, or failed deletion cleanup blocks new work for this database. | | `WORKER_QUEUE_FULL` | 500 | The service has no capacity for the request, or the request is too large. | | `WORKER_QUEUE_TIMEOUT` | 500 | A task waited too long to start. | | `INTERNAL_ERROR` | 500 | An internal service failure. A write's outcome may be unknown; check the data before retrying. | | Other SQLite codes | 500 | A SQLite engine or storage failure, such as `SQLITE_IOERR`, `SQLITE_FULL`, or `SQLITE_CORRUPT`. | `*` above includes the primary code and its extended codes. A known extended code is kept, such as `SQLITE_CONSTRAINT_UNIQUE`. An unknown extended code falls back to its primary code. When SQL access rules deny a statement, the batch returns `403 SQLITE_AUTH` with that statement's index. Project auth and lifecycle errors use these codes. Database routes include `statementIndex: null`. Admin project routes omit the index. | Code | HTTP | Fixed message | | --- | --- | --- | | PROJECT_NOT_FOUND | 404 | Project not found | | PROJECT_UNAVAILABLE | 503 | Project is being changed or deleted | | PROJECT_BUSY | 503 | Project operation is busy | | PROJECT_STORE_UNAVAILABLE | 503 | Project access is unavailable | PROJECT_NOT_FOUND and PROJECT_BUSY are used by Admin API actions. SQL and KV URLs select the project, and the key must match it. Wrong keys never disclose a project's state. See [Admin errors](#admin-errors) for response shapes and retry rules.