Datastore API
The Datastore API is a managed JSON document store with a bounded, App Engine Datastore–style query engine — multi-field filters, sorting, aggregates, and atomic transactions. Each namespace is an isolated, strongly consistent store, so reads always see the latest committed writes. All paths below are relative to a datastore instance you provision in the console.
Authentication and base URL
Send requests to https://api.altengine.net with an organization API key as a bearer token. Reads require a read grant, writes a write grant, and deletes a full grant — see Authentication.
Authorization: Bearer ae_yourkeyid.your-api-key-secretEvery path is scoped to an instance, namespace, and collection: /v1/datastore/{instance}/ns/{namespace}/col/{collection}/…. Data is organized as instance → namespace → collection → document. A namespace is an isolated store (up to 10 GB) that you create on demand — ideal for one-database-per-tenant designs; a collection is a named group of documents within it. Namespaces and collections are created automatically on first write. Use _default for the default namespace (it can't be an empty path segment).
A namespace is placed once, when it is first used: near that first request, or in the region set on the instance (console → Datastore → your instance → Settings → Region). Changing the region places only namespaces created afterwards; existing namespaces stay where they are.
Datastore requests are rate-limited per instance to a sustained 100 requests per second (6,000 per minute) by default, with a matching burst; you can set a lower limit per instance in the console (Datastore → your instance → Settings). Separately, a namespace holds at most 8 writes (puts, deletes, transactions, index builds) in flight at once; a write past that is refused. Both return 429 with a RATE_LIMITED code and Retry-After: 1. A refused write was not applied, so wait and send the same request again — even an auto-id put.
Endpoints
| Method & path | Purpose |
|---|---|
POST /v1/datastore/{instance}/ns/{ns}/col/{c}/documents | Put (insert/replace) documents in a collection. |
POST /v1/datastore/{instance}/ns/{ns}/col/{c}/documents/get | Get documents by key in one request (missing keys are omitted). |
POST /v1/datastore/{instance}/ns/{ns}/col/{c}/documents/delete | Batch delete documents by key. |
POST /v1/datastore/{instance}/ns/{ns}/col/{c}/query | Run a structured query. |
POST /v1/datastore/{instance}/ns/{ns}/col/{c}/aggregate | Run a grouped aggregate / metric query. |
POST /v1/datastore/{instance}/ns/{ns}/transaction | Run multiple operations atomically. |
GET · POST · DELETE /v1/datastore/{instance}/ns/{ns}/col/{c}/indexes | List, declare, and drop indexes. |
GET /v1/datastore/{instance}/ns | List namespaces (newest first; ?q= name search, ?limit= up to 100). When has_more is true, send the returned cursor back as ?cursor= for the next page. |
DELETE /v1/datastore/{instance}/ns/{ns} | Drop a namespace and all its data. |
Documents
A document is any JSON object, addressed by a key that is unique within its collection. Objects may nest freely; there is no schema to declare up front.
POST /v1/datastore/app/ns/acme/col/users/documents
{
"documents": [
{ "key": "u1", "data": { "name": "Ada", "age": 36, "addr": { "city": "SF" } } }
]
}
# → { "keys": ["u1"] }Putting a document with an existing key replaces it. Send up to 500 documents per request.
Every document read back — by get, query, or join — also carries created and updated, write times in milliseconds since the Unix epoch, next to key and data. A put that replaces a document keeps its created. These are the values __created__ and __updated__ sort on.
Keys: strings or numbers
Keys are strings — you can use any UTF-8 string (1–512 bytes, no NUL byte): a slug, an email, a UUID, whatever you like. A JSON number is also accepted and is stored as its decimal string, so 5 and "5" address the same document.
{ "documents": [
{ "key": "[email protected]", "data": { "plan": "pro" } },
{ "key": 1042, "data": { "sku": "A-1042" } } // stored as "1042"
] }Auto-generated keys
Omit key and the instance generates one for you; the strategy is set per instance in the console (Datastore → your instance → Settings). The generated keys come back in the keys array, in input order.
| Strategy | Behavior |
|---|---|
uuid | A random UUID (the default). |
scattered | A large random integer (as a zero-padded string) — spreads keys out to avoid hotspots. |
serial | A per-collection counter: "1", "2", "3", … |
manual | Auto-id disabled — a put without a key returns 400. |
Batch get and delete
# Get documents by key in one request (found ones only; up to 500 keys)
POST /v1/datastore/app/ns/acme/col/users/documents/get
{ "keys": ["u1", "u2", "u3"] }
# → { "documents": [ { "key": "u1", "data": { … }, "created": 1760000000000, "updated": 1760000000000 }, … ] }
# Batch delete by key (requires a full grant)
POST /v1/datastore/app/ns/acme/col/users/documents/delete
{ "keys": ["u1", "u2"] }
# → { "deleted": 2 }Caching pattern: run a query with "keys_only": true to fetch just the matching keys (a small, cheap payload), keep a client-side cache of documents you've already loaded, then batch-get only the keys you're missing.
Query
Post a structured query to a collection. where filters, order sorts, limit bounds the page, and cursor pages forward. Fields are dot-paths into the document (nested paths like addr.city work); the reserved selectors __key__, __created__, and __updated__ sort by key and by write time.
POST /v1/datastore/app/ns/acme/col/orders/query
{
"where": [
{ "field": "status", "op": "=", "value": "open" },
{ "field": "total", "op": ">", "value": 100 }
],
"order": [{ "field": "total", "dir": "asc" }],
"limit": 25
}
# → { "documents": [ { "key": "…", "data": { … } } ], "cursor": "…"|null }A sorted query takes one range filter, and sorts by it first. A query that sorts may use at most one range filter (<, <=, >, >=, !=), and that field must be the first sort key — which is why the example above orders by total rather than by __updated__. No index can serve two range filters with a sort, or a range on one field ordered by another, so such a query is refused with 400 INDEX_REQUIRED and no index will help. Sort by the range field first, or make the filter an equality. A query with no order may filter ranges on several fields.
To page, pass the returned cursor back on the next request; when it is null there are no more results. Supported operators: =, !=, <, <=, >, >=, and in (a non-empty array). Add "keys_only": true to return just the matching keys ({ "keys": [ … ], "cursor": … }) instead of full documents. A query page is capped at 500 documents (default 25). Repeated identical queries are served from cache, which a write to the same collection immediately invalidates, so you never read stale data.
Joins
Enrich each result with a referenced document from another collection via join. Each join reads the value at local_field and looks up that key in the foreign collection, attaching the result under as. The lookup is by the foreign collection's key (its primary key), so a join is always index-served — never a full scan — and bounded by the page size. Up to 5 joins per query.
POST /v1/datastore/app/ns/acme/col/orders/query
{
"where": [{ "field": "status", "op": "=", "value": "open" }],
"order": [{ "field": "total", "dir": "desc" }],
"join": [
{ "as": "customer", "collection": "users", "local_field": "user_id" }
]
}
# → { "documents": [
# { "key": "o1", "data": { "user_id": "u1", … },
# "joins": { "customer": { "key": "u1", "data": { "name": "Ada" } } } }
# ], "cursor": … }When the referenced document doesn't exist (or the local field is absent), joins.<as> is null. Joins are only for where/order queries, not keys_only ones.
A join reads the foreign collection by key without applying that collection's access rules, so joins require an organization API key — a query carrying a join from an end-user id_token is refused. Client-side apps read one collection at a time through their own row rules.
Indexes
Queries are index-served: a query that a declared index cannot satisfy returns 400 with an INDEX_REQUIRED code naming the field to index. A plain listing (no filter, no sort) needs no index.
Declare an index on one or more fields. Single-field indexes are enough to filter on several fields at once — the engine seeks one and filters the rest. A composite index (multiple fields) additionally serves a query that filters on the leading field(s) and sorts by the next, and backs grouped aggregates. Add "unique": true to enforce uniqueness of the field value within the collection.
# Declare indexes
POST /v1/datastore/app/ns/acme/col/orders/indexes
{ "fields": ["status"] } # single-field
# and, for filter-by-status + sort-by-total:
{ "fields": ["status", "total"] } # composite
# List / drop
GET /v1/datastore/app/ns/acme/col/orders/indexes
DELETE /v1/datastore/app/ns/acme/col/orders/indexes/{id}A field may carry a direction suffix, :asc (the default) or :desc. An index serves a sort whose directions all match it or all oppose it, so a uniform sort needs no suffix; a mixed sort needs one. __created__ or __updated__ may be the last field of a composite, which serves a filter sorted by write time.
# order: priority asc, due desc
{ "fields": ["priority", "due:desc"] }
# where status = "open", order: __updated__ desc
{ "fields": ["status", "__updated__"] }You can also manage indexes visually in the console (Datastore → your instance → Indexes). Declaring an index on a populated collection builds it once over the existing documents.
Auto-indexing (on by default). If a query or aggregate needs an index that doesn't exist yet, the engine creates the one it suggests and runs your request — so a query shape an index can serve does not fail while you are building. It does not rescue every query. These still return INDEX_REQUIRED, because there is no index to create: a sort combined with two range filters, or with a range on a field other than the first sort key (see Query above); a sort on __key__, __created__, or __updated__ followed by another field; and a shape that would need an index of more than 8 fields. The one-time build is billed as writes (one row per existing document), and the response includes "auto_indexed": { "fields": [ … ], "indexes": [ [ … ] ], "rows_written": N }: indexes lists every index built for the request (a read rule with OR branches can need one per branch), fields is the first of them, and rows_written is their total. It applies to end-user tokens too, so a client query can trigger a build — a collection is capped at 64 indexes, and at the cap a query that needs a new one returns 400 FAILED_PRECONDITION. Turning the setting off is how you stop client-driven builds entirely. Turn it off per instance (console → Datastore → your instance → Settings → uncheck Automatically create missing indexes) to instead get a 400 INDEX_REQUIRED naming the field to add — the right choice in production.
Aggregates & metrics
Compute grouped metrics without reading every document. metrics lists the aggregates (count, sum, avg, min, max), group lists the dimension fields, and where filters first. Grouped and filtered fields must be index-served, same as queries.
POST /v1/datastore/app/ns/acme/col/orders/aggregate
{
"group": ["region"],
"metrics": [
{ "fn": "count", "as": "orders" },
{ "fn": "sum", "field": "total", "as": "revenue" }
],
"order": [{ "field": "revenue", "dir": "desc" }]
}
# → { "groups": [ { "group": { "region": "west" }, "metrics": { "orders": 2, "revenue": 240 } } ] }as names a metric in metrics; it must match [A-Za-z_][A-Za-z0-9_]*. A metric without a valid as is named by its position — m0, m1, … — with no error. order sorts groups by a group field or a metric's name; any other field returns 400. limit caps the number of groups returned.
Cost & indexing tradeoffs
Datastore bills on three axes — reads, writes, and stored data — and indexing moves all three; see the pricing page for exact rates.
| Axis | How it's metered | How indexing moves it |
|---|---|---|
| Reads | By rows examined — one read = up to 100 rows scanned, floored at 1 per request. | An index-served query seeks straight to its matches, so a get or small page is a single read. An unindexed or unselective query scans many more rows to return the same results — and pays for every one. |
| Writes | Per row written. | Every declared index adds one index row per document. A document with no indexes is 1 write; the same document in a collection with three indexes is 4 writes. |
| Stored data | Per GB-month of what's stored. | Indexes take space too. Many or wide indexes on a large collection add up. |
So indexing is a two-sided lever, and the goal is the middle:
- Under-indexing makes queries scan (or fail with
INDEX_REQUIRED): slow, and read-expensive because you pay for every row examined. - Over-indexing makes every write more expensive and grows storage. An index on a field you never filter, sort, or group by is pure cost with no benefit.
Rule of thumb: index what you query — and nothing more. Declare an index for each field (or field combination) you actually filter, sort, or group on. Prefer a composite index when you filter and sort together — one composite is cheaper to maintain than several single-field indexes. Drop indexes you've stopped querying.
Auto-indexing (see Indexes above) can create indexes you didn't plan, each one adding write and storage cost.
Transactions
Run several operations atomically within one namespace — they all commit or none do. Transactions are strongly consistent. Operations run in order:
| Operation | Effect |
|---|---|
put / delete | Insert/replace or remove a document. A transaction containing a delete needs a full grant; without one it needs write. |
mutate | Field-level edits on one document: set a value, atomically increment a number, remove a field; upsert: true creates the document if absent. |
check | An optimistic precondition ({ key, exists }). A failed check aborts the whole transaction with 409. |
POST /v1/datastore/app/ns/acme/transaction
{
"operations": [
{ "op": "check", "collection": "accounts", "key": "a1", "exists": true },
{ "op": "mutate", "collection": "accounts", "key": "a1", "increment": { "balance": -30 } },
{ "op": "put", "collection": "ledger", "data": { "delta": -30 } }
]
}
# → { "keys": [null, "a1", "…"] }Atomic increment is race-free — concurrent increments to the same field never lose an update.
Live updates
An instance can publish a change event to a channel instance in the same organization after each committed put to a listed collection. Turn it on in the console (Datastore → your instance → Settings → Live updates): name the channel instance, then list the collections, each with an optional partition field.
| Partition field | Channel |
|---|---|
| None | <collection>, e.g. posts |
A field path, e.g. thread_id | <collection>.<value>, e.g. posts.thread-42. A document with no scalar at that path publishes nothing. |
# what a subscriber to posts.thread-42 receives
{ "channel": "posts.thread-42",
"data": { "op": "put", "ns": "acme", "collection": "posts", "keys": ["p17", "p18"] },
"ts": 1760000000000 }An event carries keys, never document data: fetch the documents with a batch get, which applies the reader's own access rules. One put publishes one event per distinct channel. ns is "" for the default namespace. Only POST …/documents publishes — deletes and transactions do not. Publishing happens after the write commits and does not affect its response; events are billed to the channel instance as messages.
Backups & restore
Every namespace has continuous point-in-time recovery for the last 30 days at no extra charge. From the console (Datastore → your instance → Backups) you can copy a namespace's current snapshot and restore the namespace to a past timestamp or a saved snapshot.
Limits
| Limit | Value |
|---|---|
| Documents per put | 500 |
| Keys per delete | 500 |
| Operations per transaction | 500 |
| Document key length | 512 bytes |
| Namespace size | 10 GB |
Query / aggregate limit | 500 (default 25) |
| Document size | 1 MiB of JSON |
| Keys per batch get | 500 |
Values in one in filter | 80 |
| Bound values in one query or aggregate | 90, shared by filter values, in entries, an end-user token's row rules, the cursor, and the page limit |
| Range filters in a sorted query | 1, on the first sort field |
| Fields per index | 8 |
| Indexes per collection | 64 |
| Field path | Up to 8 dot-separated segments, each [A-Za-z_][A-Za-z0-9_]* and not __proto__, constructor, or prototype |
| Joins per query | 5 |
| Writes in flight per namespace | 8 |
Errors
Errors use standard HTTP status codes and the same JSON shape as the rest of the API — see Errors.
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_ARGUMENT | The body is not JSON or not the expected shape (a where, order, join, or metrics that is not an array of objects, a field path or cursor that is not a string, an unknown operator or aggregate), a field path is invalid, or a value is past a limit. |
| 400 | INDEX_REQUIRED | No index serves the query or aggregate. When one would, error.details.suggested_index lists its fields; when none can (see Query), the message says why. |
| 400 | TOO_MANY_PARAMS | The compiled query needs more than 90 bound values — shorten an in list or drop a filter. |
| 400 | DOCUMENT_TOO_LARGE | One document's data is over 1 MiB of JSON. |
| 400 | FAILED_PRECONDITION | The collection already has 64 indexes — on an index create, or on a query or aggregate that auto-indexing would have built one for. |
| 401 | UNAUTHENTICATED | Missing, malformed, or expired key or token. |
| 403 | PERMISSION_DENIED | The key lacks the grant the endpoint needs; an end-user token used an organization-only endpoint (namespace list or drop, transactions, index list, create, or drop) or sent a query with a join; its access rules refused the read, write, or delete; or it named a field those rules mask in where, order, group, or a metric. |
| 404 | NOT_FOUND | No datastore instance with that name in your organization. |
| 409 | PRECONDITION_FAILED | A transaction check failed, or a mutate targeted a missing document without upsert. |
| 409 | ALREADY_EXISTS | A write would duplicate a value a unique index covers. |
| 429 | RATE_LIMITED | Over the instance's rate limit, or the namespace already has 8 writes in flight. Nothing was applied; retry after Retry-After. |
| 500 | INTERNAL | An unexpected server error. |
| 507 | INDEX_FULL | The namespace has reached its 10 GB storage limit. |