Data

A JSON document store, like Cloud Firestore. Documents live in collections, which you create on the dashboard's Data page. Each collection has an access mode instead of security rules:

Mode Who reads Who writes
owner_only (default) Each user their own documents Each user their own
authenticated_read Every signed-in user Each user their own
public_read Anyone using the app, even before sign-in Signed-in users, their own
server_only Only your backend (Admin API) Only your backend

A document belongs to the user who created it, or to the app when your backend created it. Your backend can read and write everything through the Admin API, and the dashboard's data browser can too.

Documents

A document is {"id", "fields", "owner", "version", "created_at", "updated_at"}. fields is any JSON object up to 1 MiB; owner is the owner's uid (null for the app's own documents); version goes up with every write and is also the ETag header.

Call Does
POST /data/{collection} with {"fields": {…}} Creates a document with a new id (201)
PUT /data/{collection}/{id} with {"fields": {…}} Creates (201) or replaces (200) the document with this id
PATCH /data/{collection}/{id} with {"fields": {…}} Merges: nested objects merge key by key, anything else replaces
GET /data/{collection}/{id} Gets one document (404 if missing or not readable)
GET /data/{collection} Lists documents you may read, newest first, in pages
DELETE /data/{collection}/{id} Deletes it (204)

Ids are 1–128 letters, digits, _ and -. Writing another user's document answers 403 permission-denied.

// Flutter
final created = await session.authorized('POST', '/data/orders', body: {
  'fields': {'item': 'Soup', 'price': 5.5, 'status': 'new'},
});
final id = created['data']['id'];
await session.authorized('PATCH', '/data/orders/$id', body: {'fields': {'status': 'paid'}});
// React Native
const { data: order } = await authorized('POST', '/data/orders', { fields: { item: 'Soup', price: 5.5, status: 'new' } });
await authorized('PATCH', `/data/orders/${order.id}`, { fields: { status: 'paid' } });
// iOS
let created = try await session.authorized("POST", "/data/orders", body: ["fields": ["item": "Soup", "price": 5.5, "status": "new"]])
let id = (created["data"] as! [String: Any])["id"] as! String
_ = try await session.authorized("PATCH", "/data/orders/\(id)", body: ["fields": ["status": "paid"]])
// Android
val fields = JSONObject().put("item", "Soup").put("price", 5.5).put("status", "new")
val id = session.authorized("POST", "/data/orders", JSONObject().put("fields", fields)).getJSONObject("data").getString("id")
session.authorized("PATCH", "/data/orders/$id", JSONObject().put("fields", JSONObject().put("status", "paid")))
// Web
const { data: order } = await authorized('POST', '/data/orders', { fields: { item: 'Soup', price: 5.5, status: 'new' } });
await authorized('PATCH', `/data/orders/${order.id}`, { fields: { status: 'paid' } });

Numbers keep their kind: 5.0 comes back as 5.0, so typed clients read a double.

Safe updates

To change a document only if nobody else changed it since you read it, send its version: If-Match: "3". If it changed, the answer is 412 data/version-mismatch: read it again and retry. If-None-Match: * on PUT only creates, never replaces (412 data/already-exists).

Queries

POST /data/{collection}/query finds documents you may read:

{
  "where": [["status", "==", "new"], ["price", ">=", 5], ["tags", "array-contains", "vegan"]],
  "order_by": {"field": "price", "direction": "desc", "type": "number"},
  "limit": 20
}
  • Filters must all match; at most 10. Operators: ==, !=, <, <=, >, >=, in (a list of up to 30 values) and array-contains. Fields are dot-separated paths such as address.city.
  • Values match only fields of the same kind: "5" doesn't match 5. Strings compare case-sensitively. != skips documents that don't have the field.
  • order_by sorts by one field of one type (string or number; taken from a filter on the same field when there is one); documents without such a value are left out. Without it, newest first.
  • The answer is a page like the list, with meta.next_cursor: send it back as "cursor" with the same query for the next page.
let cursor: string | null = null;
do {
  const page = await authorized('POST', '/data/orders/query', { where: [['status', '==', 'new']], cursor });
  page.data.forEach(show);
  cursor = page.meta.next_cursor;
} while (cursor);

Limits and errors

Code When
404 not-found The collection or document doesn't exist, or you may not read it
401 auth/invalid-token The collection needs a signed-in user
403 permission-denied Not your document, or a server_only collection
412 data/version-mismatch, data/already-exists See safe updates
413 data/document-too-large Over 1 MiB
422 validation/failed A bad body or query; see fields

When a user deletes their account, their documents in owner_only collections are deleted; their documents in shared collections stay and become the app's (owner null).