Your application's own document store: a **dedicated Firestore database**, provisioned when the application was registered, that only Connect can reach.

You never hold a Firebase credential, and there are no security rules to write. Connect does the authorization and the audit, and your code talks HTTPS.

### Collections are declared, not created

A collection exists because it was declared in the Console, on your application's Store tab. Writing to an undeclared name is a `403`, not an implicit create - the declaration *is* the allowlist, which is what makes this API safe to expose without a rules language.

Each declaration names two things:

| Field | Meaning |
|---|---|
| `readPermission` / `writePermission` | A permission id from your own permission catalog. A signed-in user must hold it. `null` means no per-user gate. |
| `queryableFields` | The only fields a filter, an `orderBy`, an aggregation or an index may name. |
| `indexes` | Composite indexes Connect builds in your database - what lets a query use more than one field. |

An API key acts as the **application itself**, so the declared permissions do not apply to it - your `data:read`/`data:write` scopes are its gate. They apply to a *user* reaching the store through the SDK bridge, whose permissions Connect resolves for your `client_id`.

### Every document lives under a workspace

`workspaceId` is required on every call, and it is a **path segment, not a filter** - documents live at `workspaces/{workspaceId}/{collection}/{id}`. A query cannot accidentally cross tenants because there is no query without the prefix.

```ts
const ticket = await connect.data.create({
  workspaceId,
  collection: "tickets",
  data: { subject: "Card declined", status: "open" },
});

const open = await connect.data.query({
  workspaceId,
  collection: "tickets",
  filters: [{ field: "status", op: "==", value: "open" }],
  orderBy: { field: "createdAt", direction: "desc" },
  limit: 50,
});
```

### A query needs an index, and there are two ways to have one

Firestore maintains a **single-field index automatically**. That covers one filter, or an `orderBy` on that same field, and it needs no declaration.

Anything wider - two filters, or a filter ordered by a different field - needs a **composite index**, and composite indexes are per-database. Your database is ours, so there is no `firestore.indexes.json` deploy you could make and none we could make on your behalf. Declaring one on the **Store** tab is therefore also the act that builds it: Connect reconciles the declared list into your database on every save, creating what is new and removing what is gone (an index nothing queries is still written on every document write).

A query with no matching index is a `400` that names the index to declare, in the syntax the Store tab takes:

> `tickets` has no index for this query. Declare one over status (asc), createdAt (desc) on this application's Store tab, then retry - index builds take a few minutes.

So the usual way to fill that field in is to run the query and paste the error. Field **order** matters and is not the order you wrote the query in - Firestore's rule is equality fields first, then the `array-contains` field, then the field you order by, and the refusal above always states it correctly.

Page with `cursor` (the previous page's `nextCursor`) rather than a growing `limit`.

### Dates are ISO-8601 strings

The store writes what you send it, and JSON has no date type - so send `date.toISOString()`. That is not a compromise: UTC ISO strings sort lexicographically in chronological order, so ordering and range filters behave exactly as they would on a timestamp. Connect's own `_createdAt`/`_updatedAt` come back the same way.

### Counting and summing

Never page rows into your own process to add them up. `GET /data-aggregate/{collection}` counts, sums and averages **in the database**, billed by index entries scanned rather than documents returned - so a dashboard tile stays a handful of reads as the collection grows into six figures.

```ts
const { values } = await connect.data.aggregate({
  workspaceId,
  collection: "deals",
  filters: [{ field: "outcome", op: "==", value: "won" }],
  aggregations: [
    { op: "count", alias: "count" },
    { op: "sum", field: "value", alias: "total" },
  ],
});
```

A `sum` or `avg` over nothing answers `null`, not `0` - "nothing matched" and "the total is zero" are different facts, and a tile that renders them the same way is lying about one of them.

### Writing several documents at once

`POST /data-commit` applies up to 200 writes as one transaction, across as many of your collections as they name. All of them land, or none.

**You never hold a transaction.** A transaction handle passed across an HTTP boundary is a server-side lock whose duration your network controls, with Firestore's sixty-second ceiling and lock contention as the failure mode. Instead you read, decide, and commit *conditional on nothing having moved* - and Connect runs the whole set as one short transaction on its own side.

```ts
import { increment, unchangedSince } from "@cosmoops/connect-sdk";

await connect.data.commit({
  workspaceId,
  operations: [
    { op: "create", collection: "ledger", id: entryId, data: entry },
    {
      op: "merge",
      collection: "funds",
      id: entry.fundId,
      data: { balancePaise: increment(entry.amountPaise) },
    },
  ],
});
```

That example needs no precondition at all, and the reason is worth internalising: `increment` is **commutative**, so two officers recording receipts against one fund both land correctly. Read-add-write from your own process loses one of them however carefully it retries, because the read and the write are two round trips with a gap in between.

Reach for a precondition when the new value depends on the old one in a way addition cannot express. `unchangedSince(doc)` builds one from the `_updateTime` a read returned; a stale one fails the whole commit with a `409` and writes nothing. `connect.data.transaction(build)` is that loop with the retry written once - it re-runs `build` against fresh reads, and retries only a `409`, because a `403` means the operation was wrong rather than late.

The other transforms are `serverTimestamp()`, `arrayUnion()`, `arrayRemove()` and `deleteField()`.

### Connect's own fields

Every document carries `_createdAt`, `_updatedAt`, `_createdBy` and `_updatedBy`, written by Connect, plus `_updateTime` on anything that came from a read - Firestore's own write timestamp, and the value a `{ updateTime }` precondition is compared against. `_createdBy` is `"application"` for a server-to-server write, or the Connect user id when a signed-in user drove it. You cannot write a `_`-prefixed field - that is what makes the attribution trustworthy.

### Three ways to write

| Call | If it exists | If it does not |
|---|---|---|
| `create` | `409` | Creates |
| `replace` | Overwrites every field | Creates |
| `update` | Merges top-level fields | `404` |

`commit` is the fourth, and the only one that is atomic across documents.

Writes and deletes are audited. Reads are not - the volume would swamp the trail for very little signal, and a refused read is recorded by the refusal itself.

### Declaring the collection

Nothing below works until the collection exists. In the Console, open your application, go to the **Store** tab and add a collection:

| Field | For the example below |
|---|---|
| Name | `tickets` |
| Scope | Per workspace |
| `readPermission` | `tickets:read` - a permission id from your own catalog, or `null` for no per-user gate |
| `writePermission` | `tickets:write` |
| `queryableFields` | `status`, `createdAt` - the only fields a filter, an `orderBy`, an aggregation or an index may name |
| Composite indexes | `status asc, createdAt desc` - needed for the query in the example above, which filters on one field and orders by another |

Three things follow from that form, and all three are refusals rather than surprises later. A collection name you never declared is a `403`, not an implicit create. A filter on a field you never made queryable is a `400`, not a slow scan. A query shape with no declared index is a `400` naming the index, not a runtime failure you cannot act on.

### Deriving the declaration instead of transcribing it

Filling that composite-index row in by hand works for one query. A screen with a dozen filter combinations is a dozen chances to transcribe one wrong - and the SDK ships `storeIndexFor`, which computes the index a query shape needs (or `null` when the automatic single-field index already covers it) from the same `filters`/`orderBy` your code actually sends:

```ts
import { storeIndexFor, storeIndexSignature } from "@cosmoops/connect-sdk";

const QUERY_SHAPES = [
  { filters: [{ field: "status", op: "==", value: "open" }], orderBy: { field: "createdAt", direction: "desc" } },
  // ...every other filter/sort combination this screen makes
];

const indexes = [...new Map(
  QUERY_SHAPES.map(storeIndexFor)
    .filter((fields) => fields !== null)
    .map((fields) => [storeIndexSignature(fields), fields]),
).values()];
```

Dedupe on `storeIndexSignature` before declaring - a dozen query shapes across a screen usually collapse to two or three distinct indexes, and declaring one shape twice in two field orders builds two indexes where one is dead weight on every document write. Connect runs the same derivation server-side and is the authority, so a disagreement here surfaces on the first call that index would have served rather than silently.

### A worked slice

The layering is the one in [Domain modelling with the SDK](/docs/guides/architecture): schema, use-case, procedure, hook. Connect sits at the bottom of it and nowhere else.

**1. The schema is the contract.** A store document is `{ id, data }` with `data` typed `Record<string, unknown>` on the wire - deliberately, because Connect does not know your shapes. Parse it once, at the boundary, and everything above it is typed.

```ts title="src/domain/ticket/ticket.schema.ts"
import { z } from "zod";

export const TicketStatus = z.enum(["open", "pending", "closed"]);

/** Connect writes these and refuses them from a caller - so they are read-only here. */
const ConnectFields = z.object({
  _createdAt: z.string(),
  _updatedAt: z.string(),
  _createdBy: z.string(),
});

export const Ticket = ConnectFields.extend({
  id: z.string(),
  subject: z.string().min(1).max(200),
  status: TicketStatus,
});

export const CreateTicketInput = z.object({
  subject: z.string().min(1).max(200),
});

export type Ticket = z.infer<typeof Ticket>;
export type CreateTicketInput = z.infer<typeof CreateTicketInput>;
```

**2. The use-case holds the rules.** It is the only layer that touches Connect, and the only one that decides anything.

```ts title="src/domain/ticket/ticket.use-case.ts"
import type { ConnectClient } from "@cosmoops/connect-sdk/server";
import { Ticket, type CreateTicketInput } from "./ticket.schema";

export async function createTicket(
  connect: ConnectClient,
  workspaceId: string,
  input: CreateTicketInput,
): Promise<Ticket> {
  const created = await connect.data.create({
    workspaceId,
    collection: "tickets",
    data: { ...input, status: "open" },
  });

  // Parse at the boundary. `created.data` is `Record<string, unknown>`;
  // everything above this line is a typed `Ticket`.
  return Ticket.parse({ id: created.id, ...created.data });
}

export async function listOpenTickets(
  connect: ConnectClient,
  workspaceId: string,
  cursor?: string | null,
): Promise<{ tickets: Ticket[]; nextCursor: string | null }> {
  const page = await connect.data.query({
    workspaceId,
    collection: "tickets",
    filter: { field: "status", op: "==", value: "open" },
    // Same field as the filter - see "Queries are deliberately narrow" above.
    orderBy: { field: "status", direction: "asc" },
    limit: 50,
    cursor,
  });

  return {
    tickets: page.items.map((doc) => Ticket.parse({ id: doc.id, ...doc.data })),
    // `null` is the end. Page with this rather than a growing `limit`.
    nextCursor: page.nextCursor,
  };
}
```

**3. The procedure is thin, and the workspace comes from the session.**

```ts title="src/server/router.ts"
export const router = {
  createTicket: authed
    .input(CreateTicketInput)
    .output(Ticket)
    .handler(({ input, context }) =>
      // Never `input.workspaceId`. A workspace a caller can supply is a
      // cross-tenant write one forgotten check away.
      createTicket(connect, context.session.user.workspaceId, input),
    ),
};
```

**4. The hook owns cache behaviour.** Keys in one place, invalidation by prefix, and no optimistic patch on a write the server may refuse for a quota or a permission.

```ts title="src/hooks/use-create-ticket.ts"
export const ticketKeys = { all: ["tickets"] as const };

export function useCreateTicket() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: client.createTicket,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ticketKeys.all }),
  });
}
```

### Two callers, two gates

The same method serves both, and which gate applies is decided by whether you pass `asUser`:

```ts
// Your application acting as itself. Gated by the API key's `data:write` scope;
// the collection's declared permissions do not apply.
await connect.data.create({ workspaceId, collection: "tickets", data });

// On behalf of a signed-in user, through the SDK bridge. Connect resolves that
// user's permissions for your `client_id` and checks `writePermission`.
await connect.data.create({ collection: "tickets", data, asUser: sessionToken });
```

With `asUser`, `workspaceId` is derived from the session and does not need passing - which is the same property that makes the browser path safe: there is no parameter to tamper with.

### Master data: collections that belong to no workspace

A collection can be declared **application-scoped** instead of per-workspace. Then there is one copy, owned by you, belonging to no tenant - country lists, pricing tables, prompt templates, a catalogue of device models. Every workspace reads it; none of them owns it.

Omit `workspaceId` entirely for those calls. The collection's declaration decides which shape is correct, and passing the wrong one is refused rather than quietly writing somewhere else.

```ts
const countries = await connect.data.query({ collection: "countries", limit: 200 });
```

**Master data is written with your API key only, never on behalf of a user.** A write permission is something a *workspace* grants, so honouring one here would let one tenant's admin edit reference data every other tenant reads. Reads still follow `readPermission`, because "every tenant may read the country list" and "only support staff may read the escalation matrix" are both reasonable.

Pick the scope deliberately. Tenant data in an application-scoped collection is a leak; reference data copied into every workspace is a synchronisation problem nobody asked for.

### Reading across every workspace

`GET /data-scan/{collection}` returns one collection across all of them, each row carrying its `workspaceId` - see the KV section's neighbour in the sidebar for the full contract. It needs `data:read:all`, the collection must be marked cross-workspace, and it is never available on behalf of a signed-in user.

## Endpoints

### GET `/api/v1/application/data/{collection}`

List and filter a declared collection

**Scope** `data:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Pages one workspace's documents in a collection you declared on your application's Store tab.

`filters` is a list, ANDed. **A query needs an index for its shape, and the two ways to have one are different:** Firestore maintains a single-field index automatically, which covers one filter, or an `orderBy` on that same field. Anything wider - two filters, or a filter ordered by a different field - needs a **composite index you declare** on the Store tab, and Connect builds it in your database when you save. A query with no matching index is a `400` naming the index to declare, rather than a runtime index error pointing at a console you cannot open.

Every field named in `filters` or `orderBy` must appear in the collection's `queryableFields`. `limit` defaults to 50 and caps at 200; page with `cursor`, taking each page's `nextCursor`.

Documents come back with `_updateTime`, Firestore's own write timestamp. That is the value a `{"updateTime": …}` precondition on `POST /data-commit` is compared against.

```bash
curl -G https://connect.cosmoops.com/api/v1/application/data/tickets \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  --data-urlencode 'workspaceId=WORKSPACE_ID' \
  --data-urlencode 'filters=[{"field":"status","op":"==","value":"open"}]' \
  --data-urlencode 'orderBy={"field":"createdAt","direction":"desc"}' \
  --data-urlencode 'limit=50'
```

The singular `filter` parameter is still accepted as a one-element list, for integrations built against SDK 0.13 and earlier.

#### Errors
`validation_error` (400 - unqueryable field, or no declared index for this query shape), `forbidden` (403 - key missing `data:read`, or the collection is not declared), `failed_precondition` (409 - the database is still provisioning).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes | A collection declared on the Store tab |
| `workspaceId` | query | yes |  |
| `filters` | query | no | JSON: [&#123;"field","op","value"&#125;] - ANDed |
| `filter` | query | no | Deprecated. JSON: &#123;"field","op","value"&#125; - accepted as a one-element filters list |
| `orderBy` | query | no | JSON: &#123;"field","direction"&#125; |
| `limit` | query | no |  |
| `cursor` | query | no | The previous page's nextCursor |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | A page of documents |
| `400` | validation_error |
| `403` | forbidden - key missing data:read, or collection not declared |
| `409` | failed_precondition - database not ready |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |
| `nextCursor` | `any` | yes |  |

### POST `/api/v1/application/data/{collection}`

Create a document

**Scope** `data:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Creates a document in a declared collection, under one workspace.

Pass `id` to choose the document id, or omit it and Firestore allocates one. An `id` that already exists is a `409` - use `PUT` to overwrite deliberately.

`_createdAt`, `_updatedAt`, `_createdBy` and `_updatedBy` are written by Connect; sending any `_`-prefixed field is a `400`. Documents cap at ~900 KB.

#### Errors
`validation_error` (400 - reserved field, or document too large), `forbidden` (403), `conflict` (409 - id already exists, or database not ready).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `any` | no |  |
| `id` | `any` | no |  |
| `data` | `object` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | Created |
| `400` | validation_error |
| `403` | forbidden |
| `409` | conflict - id already exists |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `data` | `object` | yes |  |

### GET `/api/v1/application/data/{collection}/{documentId}`

Read one document

**Scope** `data:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Reads a single document by id, under one workspace.

Reads are not written to the audit trail - the volume would swamp it. Writes and deletes are.

#### Errors
`not_found` (404), `forbidden` (403 - key missing `data:read`, or the collection is not declared).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes |  |
| `documentId` | path | yes |  |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The document |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `data` | `object` | yes |  |

### POST `/api/v1/application/data-get-many`

Read many documents

**Scope** `data:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Reads up to 200 documents of one collection by id, in one request.

Gated exactly like reading one document - `data:read`, a declared collection, and the signed-in user's read permission when the request carries a session token - and it spends **one** request of your rate limit, however many ids it names. Reach for it wherever you would otherwise loop over `GET /data/{collection}/{documentId}`: the contacts on a page of deals, the plans behind a list of subscriptions.

```json
{ "workspaceId": "WORKSPACE_ID", "collection": "contacts", "ids": ["c_1", "c_2", "c_9"] }
```

```json
{ "items": [{ "id": "c_1", "data": { "name": "Asha" } }, null, { "id": "c_9", "data": { "name": "Ravi" } }] }
```

`items` is aligned with `ids`, with `null` where a document does not exist - a missing document is an ordinary answer here, not a `404`. A POST because the ids travel in the body; it writes nothing.

#### Errors
`validation_error` (400 - no ids, or more than 200), `forbidden` (403 - key missing `data:read`, or the collection is not declared).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `any` | no |  |
| `collection` | `string` | yes |  |
| `ids` | `array` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The documents, aligned with ids |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |

### PUT `/api/v1/application/data/{collection}/{documentId}`

Replace or merge a document

**Scope** `data:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Writes a document at a known id.

`PUT` **replaces** every field. Send the **header** `X-Store-Write-Mode: merge` to merge top-level fields into an existing document instead - a merge into a document that does not exist is a `404`, because a partial document that looks whole is worse than a refusal.

`_createdAt` and `_createdBy` survive a replace; only `_updatedAt`/`_updatedBy` move.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - merge into a missing document).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes |  |
| `documentId` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `any` | no |  |
| `data` | `object` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Written |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found - merge into a missing document |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `data` | `object` | yes |  |

### DELETE `/api/v1/application/data/{collection}/{documentId}`

Delete a document

**Scope** `data:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Deletes a document. Idempotent - deleting a document that is not there succeeds, because the end state is what was asked for.

Audited by collection and id, never by contents.

#### Errors
`forbidden` (403 - key missing `data:write`, or the collection is not declared).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes |  |
| `documentId` | path | yes |  |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `204` | Deleted |
| `403` | forbidden |
| `429` | rate_limited |

### GET `/api/v1/application/data-scan/{collection}`

Read one collection across every workspace

**Scope** `data:read:all` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Your own back-office view: one collection, every workspace, one page at a time. Each row carries the `workspaceId` it came from.

This is the only read in the store that leaves the per-workspace path everything else is built on, so it is fenced four ways:

| Fence | Where |
|---|---|
| `data:read:all` on the key | This endpoint |
| The collection is marked **cross-workspace** | Store tab, per collection |
| The caller is your application, never one of your users | Always - there is no permission that grants it |
| The scan is written to the audit trail | Always - ordinary reads are not |

The third is the one worth understanding. A workspace admin is an admin *of their workspace*; letting a role unlock this would mean one customer reading another's data. So there is no `asUser` form of this call and no permission that enables it - only your own server credential.

##### Cursors and indexes

A scan cursor is `{workspaceId}/{documentId}`, not a bare id - an id alone does not identify a document across workspaces.

Filtering and ordering follow the same index rules as a scoped query, and additionally need **collection-group scope**. Connect creates the single-field ones when you mark a field queryable on a cross-workspace collection, and creates a collection-group copy of every composite index the collection declares. A scan issued in the minutes after that declaration can fail while the index builds.

Omit `workspaceId` - passing one is a `400`, because a scan that named a workspace would be a scoped read wearing the wrong name.

#### Errors
`validation_error` (400 - a workspaceId was passed, or the query needs a composite index), `forbidden` (403 - key missing `data:read:all`, the collection is not marked cross-workspace, or the request was made on behalf of a user).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes | A collection marked cross-workspace on the Store tab |
| `filters` | query | no | JSON: [&#123;"field","op","value"&#125;] - ANDed |
| `filter` | query | no | Deprecated. JSON: &#123;"field","op","value"&#125; |
| `orderBy` | query | no | JSON: &#123;"field","direction"&#125; |
| `limit` | query | no |  |
| `cursor` | query | no | The previous page's nextCursor - &#123;workspaceId&#125;/&#123;documentId&#125; |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | A page of documents from across every workspace |
| `400` | validation_error |
| `403` | forbidden - not scannable, or acting for a user |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |
| `nextCursor` | `any` | yes |  |

### GET `/api/v1/application/data-aggregate/{collection}`

Count, sum and average a collection

**Scope** `data:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Aggregates a filtered collection **in Firestore**, and answers with one number per alias.

This is the endpoint that keeps a dashboard from being an unbounded read. Firestore bills an aggregation by the index entries it scans rather than by the documents it would have returned, so a pipeline total over ten thousand deals costs a handful of reads - where fetching the rows and summing them in your own process costs ten thousand, and gets slower every month.

`aggregations` is JSON, up to five per request:

```bash
curl -G https://connect.cosmoops.com/api/v1/application/data-aggregate/deals \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  --data-urlencode 'workspaceId=WORKSPACE_ID' \
  --data-urlencode 'filters=[{"field":"outcome","op":"==","value":"won"}]' \
  --data-urlencode 'aggregations=[{"op":"count","field":null,"alias":"count"},{"op":"sum","field":"value","alias":"total"}]'
```

```json
{ "values": { "count": 42, "total": 1875000 } }
```

`count` takes no field and `sum`/`avg` require one - a `sum` with no field has no meaning, so it is refused rather than defaulted to a count. A `sum` or `avg` over nothing answers `null` rather than `0`, because "nothing matched" and "the total is zero" are different facts.

##### Indexes

An aggregation needs the index its **filters** would need, *plus the aggregated field*: Firestore reads a sum out of the index rather than out of the documents. Declaring `[outcome asc, value asc]` is what serves the example above. A `count` needs nothing beyond what its filters already require.

There is no `orderBy`, `limit` or `cursor`: the aggregate of an ordered page is the aggregate of the whole filtered set, so they would be three arguments that change nothing.

#### Errors
`validation_error` (400 - malformed aggregations, an unqueryable field, or no declared index), `forbidden` (403 - key missing `data:read`, or the collection is not declared).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `collection` | path | yes |  |
| `workspaceId` | query | yes |  |
| `aggregations` | query | yes | JSON: [&#123;"op","field","alias"&#125;] - op is count \| sum \| avg |
| `filters` | query | no | JSON: [&#123;"field","op","value"&#125;] - ANDed |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | One number per alias |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `values` | `object` | yes |  |

### POST `/api/v1/application/data-commit`

Apply writes atomically

**Scope** `data:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Applies up to 200 writes as one transaction, across as many of your declared collections as they name. All of them land, or none.

**You never hold a transaction.** Handing one out across an HTTP boundary would be a server-side lock whose duration your network controls, with Firestore's sixty-second ceiling and lock contention as the failure mode. Instead you read, decide, and commit *conditional on nothing having moved*:

```json
{
  "workspaceId": "WORKSPACE_ID",
  "operations": [
    { "op": "create", "collection": "ledger", "id": "e_1", "data": { "amountPaise": 50000 } },
    {
      "op": "merge",
      "collection": "funds",
      "id": "general",
      "data": { "balancePaise": { "__transform": "increment", "by": 50000 } },
      "precondition": { "updateTime": "2026-09-04T10:11:12.345Z" }
    }
  ]
}
```

A precondition that no longer holds is a `409` and **nothing is written** - re-read, rebuild, retry. `updateTime` is the `_updateTime` a read returned; `{"exists": true|false}` is the other form.

##### Transforms

A value may be a transform sentinel instead of a literal, at the top level of `data`:

| Sentinel | Effect |
|---|---|
| `{"__transform":"increment","by":N}` | Adds N. **Commutative** - concurrent increments all land, with no precondition and no retry. |
| `{"__transform":"serverTimestamp"}` | Connect's write time. |
| `{"__transform":"arrayUnion","values":[…]}` | Adds the values that are not already there. |
| `{"__transform":"arrayRemove","values":[…]}` | Removes them. |
| `{"__transform":"delete"}` | Removes the field. `merge` only - a replace already drops what it does not list. |

`increment` is the one that genuinely cannot be done any other way: read-modify-write from your own process loses one of two concurrent updates however carefully it retries.

##### Rules

`create` takes no precondition - it already means "and it must not exist". `merge` requires the document to exist. The same document may not be written twice in one commit, because Firestore would apply both in argument order and the outcome would depend on it. Every collection named is authorized on its own, so a commit cannot reach one you could not write to a document at a time.

#### Errors
`validation_error` (400 - malformed operations, a reserved field, or the same document twice), `forbidden` (403 - key missing `data:write`, or a collection is not declared), `conflict` (409 - a precondition no longer holds).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `any` | no |  |
| `operations` | `array` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The id each operation wrote, in order |
| `400` | validation_error |
| `403` | forbidden |
| `409` | conflict - a precondition no longer holds |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `ids` | `array` | yes |  |