A sealed store keeps a person's records in sync across their devices without Aitronos being able to read them. The client encrypts every record before it leaves the device. The server stores the ciphertext, orders the changes, arbitrates conflicting writes between devices, keeps a short history, and removes what has expired. It never holds a key that opens a record, and it never interprets what is inside one.
Every path starts with https://api.aitronos.com/v1/sealed-stores/{store}.
One store is available today:
| Store | Kind | Live records per person |
|---|---|---|
aitronos-browser | session | 500 |
aitronos-browser | favourites | 1 |
Any other store name answers 404 SEALED_STORE_NOT_FOUND, and any other kind answers 422 SEALED_KIND_INVALID. Only live records count towards a cap; deleted records do not. Get a sealed store's limits returns the caps, every other limit on this page, and how many live records of each kind the caller holds, so a client can check a save before it starts.
Sealed stores are reached only with a person's own sign-in session: Authorization: Bearer <access token>, where the token comes from that person signing in. API keys of any kind and service accounts are refused with 403 SEALED_STORE_PERSONAL_SESSION_REQUIRED, because nothing but the person may act on their encrypted data. A missing or invalid token is 401. As on every signed-in route, the person must belong to an organization, or the call is 403 NO_ORGANIZATION_MEMBERSHIP; the store itself belongs to the person, not to an organization, so it is the same store whichever organization is active.
Each person's store is their own. A record id that belongs to somebody else answers 404 SEALED_RECORD_NOT_FOUND, exactly like an id that was never created.
Every record is two parts.
The envelope is what the server needs to do its job, and it is readable:
| Field | Set by | Meaning |
|---|---|---|
id | client | The record's id, minted randomly by the client: 16 to 128 characters of A-Z a-z 0-9 _ -. Never derive it from the content. |
kind | client | session or favourites. Fixed at create. |
schema_version | client | The client's own schema and sealing version for this value, 1 to 2,147,483,647. Stored and returned as sent. |
device_id | client | The device of the last accepted write: the device id the device signed in with, 16 to 255 characters of A-Z a-z 0-9 . _ : @ -. |
revision | server | 1 at create, plus 1 on every accepted write, delete and undelete included. |
created_at | server | When the record was created. |
last_touched_at | server | Set on every accepted write. Sort by it, ties by id. |
deleted, deleted_at | server | Whether the record is a tombstone, and since when. |
payload_bytes | server | The decoded size of the payload; null for a tombstone. |
Values the server owns are ignored if a client sends them.
The sealed content is opaque to the server:
payload— the encrypted bytes, standard padded base64. At least 28 bytes decoded (a 12-byte nonce plus a 16-byte tag) and at most 512 KiB (524,288 bytes) decoded.wrapped_dek— the record's data key, wrapped to the account's identity public key with an ephemeral X25519 key agreement and an AEAD:
{"v": 1, "aead": "aes-256-gcm", "nonce": "<base64 12 bytes>", "ct": "<base64 48 bytes>", "epk": "<base64 32 bytes>"}v is accepted as 1 or "1" and always returned as the integer 1. aead is aes-256-gcm or chacha20-poly1305. ct is the wrapped key plus its tag. A wrapped key of the wrong shape is 422 SEALED_WRAPPED_KEY_INVALID; an unknown field inside it is 422 VALIDATION_ERROR.
The payload ceiling counts the payload alone. wrapped_dek is a small fixed-size key wrap and is not counted. A payload over the ceiling is 413 SEALED_PAYLOAD_TOO_LARGE; one that is not standard padded base64, or is shorter than 28 bytes, is 422 SEALED_PAYLOAD_INVALID. These are the only checks the server makes on sealed content: that it decodes and that its size is in bounds.
A deleted record (a tombstone) is envelope-only: deleted is true, and wrapped_dek, payload and payload_bytes are null.
All byte values are standard padded base64. All times are ISO 8601 UTC with a Z, to the microsecond.
Every POST, PUT and DELETE requires an Idempotency-Key header: a random id the client mints once per change and sends again on every retry of that change. It is 16 to 128 printable ASCII characters with no spaces; a UUID works. A missing key is 422 SEALED_IDEMPOTENCY_KEY_REQUIRED and a malformed one 422 SEALED_IDEMPOTENCY_KEY_INVALID.
- A replay answers the first answer. The first accepted answer to a key is kept for 30 days, up to the newest 50,000 keys per store. Sending the same key with the same call again answers the exact same status and bytes, with the header
Idempotent-Replayed: true, and changes nothing, even if the record has changed since. A first answer carriesIdempotent-Replayed: false. - A refused call is not remembered. A
409,413or422stores nothing, so after merging, the client may retry with the same key. - One key, one call. The same key sent for a different call (another endpoint, another record, or another body) is
422 SEALED_IDEMPOTENCY_KEY_REUSED. - Keys are scoped to the person and the store.
Every write is also idempotent by its own rules, so a retry whose remembered answer has aged out never makes a duplicate: a create with the same id answers the record that id names (200), an update or delete naming an old revision answers 409 with the current record, and a superseded append with the same idempotency_key answers the stored entry (200).
The server takes one lock per person and store for every write, so revisions, the change sequence and the caps can never race each other. Writes sent at the same time therefore wait for each other. A write that waits more than 2 seconds for that lock is 503 SERVICE_TEMPORARILY_UNAVAILABLE with retry_after, and nothing was written: send it again with the same Idempotency-Key and the same body. Sending one store's writes one at a time, or a few at once, keeps the wait short.
An accepted create, update, delete or undelete answers the record's envelope (object: "sealed_record_envelope"), with its new revision and last_touched_at and without a copy of the sealed content. The writer already holds what it sent. To read the content, get the record or read the change feed, for example after an undelete.
A write names the revision it was based on. When the record has moved on, or is in the wrong state for the write, the answer is a 409 that carries the full record as the server holds it now in error.details.current: the envelope and the payload, or the tombstone. The client merges and writes again without another read.
| Code | When |
|---|---|
SEALED_RECORD_REVISION_CONFLICT | The revision sent is not the record's current revision. |
SEALED_RECORD_DELETED | The record is a tombstone. Undelete it before writing. This wins over a stale revision. |
SEALED_RECORD_NOT_DELETED | Undelete of a record that is live. |
SEALED_RECORD_ID_CONFLICT | Create with an id that already names a record of another kind. |
SEALED_RECORD_SINGLETON_EXISTS | Create (or undelete) of a favourites record when the person already holds the live one. current is that record: adopt it. |
List changes is the sync read.
- Full read. Without
since, it returns every record in the store, live and tombstoned, in change order. - Delta read. With
since, it returns every record created, updated, deleted or undeleted after that cursor, each once, in its current state. A deleted record arrives as its tombstone.
Follow next_cursor while has_more is true, then keep the last next_cursor for the next sync. Cursors are opaque: store them and send them back, never parse or build one.
A cursor issued more than 30 days ago, or one from before a tombstone that has since been removed, is refused with 410 SEALED_CURSOR_EXPIRED. It means the delta can no longer be complete: do a full read and reconcile the device's records against it. A cursor this store did not issue is 422 SEALED_CURSOR_INVALID; treat it the same way.
- Tombstones. A delete turns the record into a tombstone. It appears in the feed and in List deleted records, and it can be undeleted for 30 days, after which it is removed for good. A store keeps at most its newest 500 tombstones: a delete past that removes the oldest tombstone at once, before its 30 days are up.
- Revision history. Every create, update and undelete keeps a copy of the content for 30 days, at most the newest 100 per record and 1,000 across the store (past that, the store's oldest revisions go first, whichever record they belong to). A delete keeps no content. See List a record's revisions.
- Superseded values. A value a client-side merge discarded, such as the name that lost a rename on another device, can be kept on the record so the person can recover it. Kept 30 days, at most 200 per record and 2,000 per store, each at most 64 KiB (65,536 bytes) decoded. A full log refuses new entries rather than dropping old ones.
- Account key blob. Each store holds one account-scoped opaque blob, at most 2 KiB (2,048 bytes) decoded, that every one of the person's signed-in devices can read. The first create wins and it is never replaced.
| Limit | Value |
|---|---|
Live session records per person | 500 |
Live favourites records per person | 1 |
| Sealed payload per record | 512 KiB (524,288 bytes) decoded, counting payload alone, not wrapped_dek |
| Smallest sealed value | 28 bytes decoded |
| Superseded entry payload | 64 KiB (65,536 bytes) decoded |
| Superseded entries | 200 per record and 2,000 per store, within 30 days |
| Account key blob | 2 KiB (2,048 bytes) decoded |
| Revision history | 100 per record and 1,000 per store, within 30 days |
| Tombstones | 500 per store, each for 30 days |
Remembered Idempotency-Key answers | The newest 50,000 per store, each for 30 days |
| Retention | 30 days, for tombstones, history, superseded entries, Idempotency-Key answers and feed cursors |
| Feed page | limit 1 to 500 (default 100); a page also stops early once it carries 8 MiB of payload, never below one record |
| Deleted and superseded list pages | limit 1 to 500 (default 100) |
Every error uses the standard error format:
{
"success": false,
"error": {
"code": "SEALED_RECORD_NOT_FOUND",
"message": "That item isn't synced.",
"system_message": "no record with this id in this store",
"type": "client_error",
"status": 404,
"details": {},
"trace_id": "9d63d4ed-24bb-4579-81cf-a78ff1e78c0c",
"timestamp": "2026-10-10T09:12:40.318552Z"
}
}message is written for the person using the app; system_message is for the developer.
| Status | Code | Meaning |
|---|---|---|
| 401 | AUTHENTICATION_REQUIRED | No access token, or one that is not valid. |
| 403 | SEALED_STORE_PERSONAL_SESSION_REQUIRED | The call used an API key or a service account. Only a person's own sign-in session reaches a sealed store. |
| 403 | NO_ORGANIZATION_MEMBERSHIP | The signed-in person belongs to no organization. |
| 404 | SEALED_STORE_NOT_FOUND | No store with that name (details.store). |
| 404 | SEALED_RECORD_NOT_FOUND | No record with that id in the caller's store, or its tombstone has passed the 30-day retention. |
| 404 | SEALED_REVISION_NOT_FOUND | That revision is a delete (it has no content) or is no longer in the kept history (details.revision). |
| 404 | SEALED_ACCOUNT_KEY_NOT_FOUND | No device has created the store's account key blob yet. |
| 409 | SEALED_RECORD_REVISION_CONFLICT | The revision sent is stale. details.current is the record now. |
| 409 | SEALED_RECORD_DELETED | The record is a tombstone. details.current is the tombstone. |
| 409 | SEALED_RECORD_NOT_DELETED | Undelete of a live record. details.current is the record. |
| 409 | SEALED_RECORD_ID_CONFLICT | The id already names a record of another kind. details.current is that record. |
| 409 | SEALED_RECORD_SINGLETON_EXISTS | The one live favourites record already exists. details.current is that record. |
| 409 | SEALED_OPERATION_IN_PROGRESS | An earlier call with the same Idempotency-Key has not finished. Retry in a moment. |
| 410 | SEALED_CURSOR_EXPIRED | The feed cursor is too old to be complete (details.retention_days). Do a full read and reconcile. |
| 413 | SEALED_PAYLOAD_TOO_LARGE | A sealed value is over its ceiling (details.field, details.max_bytes, details.payload_bytes). |
| 422 | SEALED_PAYLOAD_INVALID | A sealed value is not standard padded base64, or decodes to fewer than 28 bytes (details.field). |
| 422 | SEALED_WRAPPED_KEY_INVALID | wrapped_dek is not a wrapped key of the documented shape (details.field). |
| 422 | SEALED_KIND_INVALID | The store does not hold that kind (details.kind, details.allowed). |
| 422 | SEALED_RECORD_LIMIT_REACHED | The person already holds the most live records of that kind (details.kind, details.limit). Delete one to add another. |
| 422 | SEALED_SUPERSEDED_LIMIT_REACHED | The record or the store already holds the most superseded entries (details.limit; details.scope is record or store). |
| 422 | SEALED_CURSOR_INVALID | The cursor was not issued by this store. Do a full read. |
| 422 | SEALED_IDEMPOTENCY_KEY_REQUIRED | A write without an Idempotency-Key header. |
| 422 | SEALED_IDEMPOTENCY_KEY_INVALID | The Idempotency-Key is not 16 to 128 printable ASCII characters without spaces. |
| 422 | SEALED_IDEMPOTENCY_KEY_REUSED | The Idempotency-Key was already used for a different call. Mint a new one for a new change. |
| 422 | VALIDATION_ERROR | A parameter or body field is missing, of the wrong type or out of range (for example an id that does not match its pattern, a revision above 9,223,372,036,854,775,807, or an unknown field in wrapped_dek). |
| 503 | SERVICE_TEMPORARILY_UNAVAILABLE | The write waited too long for the store's lock (see Writes to one store are serial). Nothing was written; retry with the same Idempotency-Key after retry_after seconds. |
| Method | Path | Page |
|---|---|---|
| GET | /v1/sealed-stores/{store} | Get a sealed store's limits |
| POST | /v1/sealed-stores/{store}/records | Create a sealed record |
| GET | /v1/sealed-stores/{store}/records/{record_id} | Get a sealed record |
| PUT | /v1/sealed-stores/{store}/records/{record_id} | Update a sealed record |
| DELETE | /v1/sealed-stores/{store}/records/{record_id} | Delete a sealed record |
| POST | /v1/sealed-stores/{store}/records/{record_id}/undelete | Undelete a sealed record |
| GET | /v1/sealed-stores/{store}/changes | List sealed store changes |
| GET | /v1/sealed-stores/{store}/deleted | List deleted sealed records |
| GET | /v1/sealed-stores/{store}/records/{record_id}/revisions | List a sealed record's revisions |
| GET | /v1/sealed-stores/{store}/records/{record_id}/revisions/{revision} | Get a sealed record revision |
| POST | /v1/sealed-stores/{store}/records/{record_id}/superseded | Append a superseded value |
| GET | /v1/sealed-stores/{store}/records/{record_id}/superseded | List superseded values |
| POST | /v1/sealed-stores/{store}/account-key | Create the account key blob |
| GET | /v1/sealed-stores/{store}/account-key | Get the account key blob |