← Back to API docs

Docs

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 — without exposing raw SQL. 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-secret

Every path is scoped to an instance: /v1/datastore/{instance}/…. 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.

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). Over the limit, a request returns 429 with a RATE_LIMITED code; back off and retry.

Endpoints

Datastore API endpoints
Method & pathPurpose
POST /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/documentsPut (insert/replace) documents in a collection.
GET /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/documents/{key}Get one document by key.
POST /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/documents/batchGetGet multiple documents by key in one request.
POST /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/documents/deleteBatch delete documents by key.
POST /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/queryRun a structured query.
POST /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/aggregateRun a grouped aggregate / metric query.
POST /v1/datastore/{instance}/namespaces/{ns}/transactionRun multiple operations atomically.
GET · POST · DELETE /v1/datastore/{instance}/namespaces/{ns}/collections/{c}/indexesList, declare, and drop indexes.
GET /v1/datastore/{instance}/namespacesList namespaces (newest first; ?q= name search, ?limit= up to 100 with has_more).
DELETE /v1/datastore/{instance}/namespaces/{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/namespaces/acme/collections/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.

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. (There is no separate integer-key type — every key is ultimately a string.)

{ "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.

Auto-ID strategies
StrategyBehavior
uuidA random UUID (the default).
scatteredA large random integer (as a zero-padded string) — spreads keys out to avoid hotspots.
incrementingA per-collection counter: "1", "2", "3", …
manualAuto-id disabled — a put without a key returns 400.

Get, batch get, and delete

# Get one document
GET /v1/datastore/app/namespaces/acme/collections/users/documents/u1
# → { "document": { "key": "u1", "data": { … } } }

# Get many documents in one request (found ones only; up to 500 keys)
POST /v1/datastore/app/namespaces/acme/collections/users/documents/batchGet
{ "keys": ["u1", "u2", "u3"] }
# → { "documents": [ { "key": "u1", "data": { … } }, … ] }

# Batch delete by key (requires a full grant)
POST /v1/datastore/app/namespaces/acme/collections/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 batchGet 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/namespaces/acme/collections/orders/query
{
  "where": [
    { "field": "status", "op": "=",  "value": "open" },
    { "field": "total",  "op": ">",  "value": 100 }
  ],
  "order": [{ "field": "__updated__", "dir": "desc" }],
  "limit": 25
}
# → { "documents": [ { "key": "…", "data": { … } } ], "cursor": "…"|null }

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 lighter response you can pair with batchGet for client-side caching. A query page is capped at 500 documents (default 25). Repeated identical queries are served from a fast in-memory cache that 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/namespaces/acme/collections/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.

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 — this keeps every query fast and cost-bounded (no accidental full-collection scans). 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/namespaces/acme/collections/orders/indexes
{ "fields": ["status"] }                 # single-field
# and, for filter-by-status + sort-by-total:
{ "fields": ["status", "total"] }        # composite

# List / drop
GET    /v1/datastore/app/namespaces/acme/collections/orders/indexes
DELETE /v1/datastore/app/namespaces/acme/collections/orders/indexes/{id}

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 you never hit INDEX_REQUIRED while building. The one-time build is billed as writes (one row per existing document), and the response includes "auto_indexed": { "fields": [ … ] } so it's never silent. 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, where you want to decide exactly which indexes exist rather than have queries create them for you.

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/namespaces/acme/collections/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 } } ] }

Cost & indexing tradeoffs

Datastore bills on three axes — reads, writes, and stored data — and indexing is the lever that moves all three. A minute spent here keeps your bill predictable; see the pricing page for exact rates.

How each billing axis works and how indexing affects it
AxisHow it's meteredHow indexing moves it
ReadsBy 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.
WritesPer 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 dataPer 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. And because reads are priced by rows examined, a selective filter on an indexed field is not just faster — it's directly cheaper than a broad one that scans the collection.

This is also the tradeoff behind auto-indexing (see Indexes above): leaving it on is convenient in development but can create indexes you didn't plan — each one adding write and storage cost — so in production many teams turn it off and declare indexes deliberately.

Transactions

Run several operations atomically within one namespace — they all commit or none do. Transactions within a namespace are strongly consistent: everything commits together or the whole transaction aborts. Operations run in order:

Transaction operation types
OperationEffect
put / deleteInsert/replace or remove a document.
mutateField-level edits on one document: set a value, atomically increment a number, remove a field; upsert: true creates the document if absent.
checkAn optimistic precondition ({ key, exists }). A failed check aborts the whole transaction with 409.
POST /v1/datastore/app/namespaces/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.

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 — useful for undoing a bad deploy or bulk edit.

Limits

Datastore API limits
LimitValue
Documents per put500
Keys per delete500
Operations per transaction500
Document key length512 bytes
Namespace size10 GB
Query / aggregate limit500 (default 25)

Errors

Errors use standard HTTP status codes and the same JSON shape as the rest of the API. Beyond the common codes, Datastore adds:

Datastore-specific error codes
StatusCodeMeaning
400INDEX_REQUIREDThe query is not served by an index — declare one on the filtered/sorted field.
409PRECONDITION_FAILEDA transaction check failed, or a mutate targeted a missing document without upsert.
507INDEX_FULLThe namespace has reached its 10 GB storage limit.