<!-- Source: https://docs.geopera.com/api-reference/guides/managing-data · Markdown for LLMs -->

# Managing data

Every piece of imagery the platform delivers lands as an **item** with one or more **assets**, optionally grouped into a **collection**, and all of it is reached through typed operations invoked as `POST /v1/op/{operation_id}` with a JSON body — there are no REST verbs or path parameters. This guide covers reading, organizing, editing, and removing that data; see [Operations](/api-reference/operations) for the invocation model and [Concepts](/api-reference/concepts) for how items, collections, and assets relate.

## The data model

| Noun       | What it is                                                                      | Identified by   |
| ---------- | ------------------------------------------------------------------------------- | --------------- |
| Item       | A single catalog record (a STAC Feature) — one scene, mosaic, or derived layer. | `item_id`       |
| Collection | A named grouping of items within a project, with map display order and colour.  | `collection_id` |
| Asset      | A file attached to an item (the COG, a thumbnail, sidecar metadata).            | `asset_id`      |

An item always belongs to exactly one project. It may belong to zero or one collection (`collection_id` is nullable). Deleting a collection unlinks its items rather than deleting them — see [Soft delete and expiry](#soft-delete-and-expiry).

Every read operation gates on membership: you must be a member of the item's or collection's project (or its parent organization) to see it. Writes additionally require an `editor` or `admin` role; collection deletion requires `admin`/`owner`. See [Scopes](/api-reference/scopes) for the full matrix.

## Operations at a glance

### Items

| Operation                  | Side effect  | Scope         | Purpose                                                     |
| -------------------------- | ------------ | ------------- | ----------------------------------------------------------- |
| `items.list`               | read         | `items:read`  | List a project's items, optionally filtered by collection.  |
| `items.list_by_collection` | read         | `items:read`  | List items in one collection.                               |
| `items.get`                | read         | `items:read`  | Fetch a single item by id.                                  |
| `items.get_stac`           | read         | `items:read`  | Fetch an item as a STAC 1.0.0 Feature with embedded assets. |
| `items.search`             | read         | `items:read`  | Search a project's items with STAC filters.                 |
| `items.search_org`         | read         | `items:read`  | Search every item across your organization.                 |
| `items.list_assets`        | read         | `items:read`  | List the files attached to an item.                         |
| `items.lineage`            | read         | `items:read`  | Get an item's parent/child lineage tree.                    |
| `items.create`             | compute      | `items:write` | Create an item in a project.                                |
| `items.update`             | compute      | `items:write` | Patch an item's mutable metadata.                           |
| `items.duplicate`          | compute      | `items:write` | Copy an item with its assets and viz profiles.              |
| `items.delete`             | destructive  | `items:write` | Soft-delete an item.                                        |
| `items.asset.download`     | share_export | `items:read`  | Issue a signed download URL for an asset.                   |

### Collections

| Operation            | Side effect | Scope               | Purpose                                        |
| -------------------- | ----------- | ------------------- | ---------------------------------------------- |
| `collections.list`   | read        | `collections:read`  | List a project's collections with item counts. |
| `collections.get`    | read        | `collections:read`  | Get one collection by id.                      |
| `collections.create` | compute     | `collections:write` | Create a collection in a project.              |
| `collections.update` | compute     | `collections:write` | Update a collection's metadata.                |
| `collections.delete` | destructive | `collections:write` | Soft-delete a collection (unlinks its items).  |

### Assets

| Operation       | Side effect | Scope         | Purpose                       |
| --------------- | ----------- | ------------- | ----------------------------- |
| `assets.delete` | destructive | `items:write` | Delete one file from an item. |

## Listing and fetching items

`items.list` returns a project's active (non-deleted) items in an `{ items, total }` envelope. Pass `collection_id` to scope to one collection, or `uncollected: true` to return only items not assigned to any collection. The `limit`/`offset` pair drives offset pagination — see [Pagination](/api-reference/pagination) for cursor conventions and the `total` field.

```http
POST /v1/op/items.list HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "project_id": "8f2c1e4a-...",
  "collection_id": "3a9b...",
  "limit": 50,
  "offset": 0
}
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [
    {
      "id": "c71d...",
      "project_id": "8f2c1e4a-...",
      "collection_id": "3a9b...",
      "name": "Field 12 — 2026-05-18",
      "type": "image",
      "cloud_cover": 4.1,
      "gsd": 0.5,
      "lifecycle_state": "active"
    }
  ],
  "total": 137
}
```

To read a single item, call `items.get` with just `item_id`. It returns the item wrapped in an `item` object and raises a `404` if the item is missing or soft-deleted, `403` if you lack project access.

```python
from geopera import Geopera

client = Geopera(api_key="gpra_your_api_key")

page = client.op("items.list", project_id=PROJECT_ID, limit=50)
print(page["total"], "items")

item = client.op("items.get", item_id=page["items"][0]["id"])
print(item["item"]["name"], item["item"]["lifecycle_state"])
```

```typescript
import { Geopera } from '@geopera/sdk';

const client = new Geopera({ apiKey: 'gpra_your_api_key' });

const page = await client.op('items.list', { project_id: PROJECT_ID, limit: 50 });
console.log(page.total, 'items');

const { item } = await client.op('items.get', { item_id: page.items[0].id });
console.log(item.name, item.lifecycle_state);
```

### STAC output

If your tooling speaks STAC, `items.get_stac` returns a full STAC 1.0.0 Feature (`application/geo+json`) with assets and links embedded. Pass an optional `base_url` to control the absolute link hrefs.

```http
POST /v1/op/items.get_stac HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{ "item_id": "c71d...", "base_url": "https://catalog.example.com" }
```

## Searching items

`items.search` filters a single project's items with STAC-compatible parameters; `items.search_org` runs the same query across your whole organization. Both return a `FeatureCollection` with `numberMatched`. Supported filters:

| Field              | Type                         | Notes                                                   |
| ------------------ | ---------------------------- | ------------------------------------------------------- |
| `bbox`             | `[west, south, east, north]` | Exactly four values.                                    |
| `datetime`         | string                       | ISO 8601 instant or range.                              |
| `collections`      | string[]                     | Restrict to these collection ids.                       |
| `type`             | string                       | Item type filter.                                       |
| `query`            | object                       | STAC property query.                                    |
| `sortby`           | object[]                     | Sort directives.                                        |
| `limit` / `offset` | int                          | `limit` ≤ 1000.                                         |
| `format`           | string                       | Set to `"stac"` to enrich each feature with its assets. |

```http
POST /v1/op/items.search HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "project_id": "8f2c1e4a-...",
  "bbox": [149.0, -35.4, 149.3, -35.1],
  "datetime": "2026-01-01T00:00:00Z/2026-06-01T00:00:00Z",
  "query": { "cloud_cover": { "lt": 10 } },
  "limit": 100
}
```

```python
fc = client.op(
    "items.search",
    project_id=PROJECT_ID,
    bbox=[149.0, -35.4, 149.3, -35.1],
    datetime="2026-01-01T00:00:00Z/2026-06-01T00:00:00Z",
    query={"cloud_cover": {"lt": 10}},
    limit=100,
)
print(fc["numberMatched"], "matches")
```

By default each feature is the raw item record. Pass `format="stac"` to expand features into full STAC Features with their assets attached — useful when feeding a STAC-aware viewer, costlier because each feature triggers an asset lookup.

## Updating items

`items.update` patches mutable metadata. Send `item_id` plus only the fields you want to change. The update is driven by which fields are present in the body, so sending an explicit `null` **clears** that column, while omitting a field leaves it untouched.

Mutable fields: `collection_id`, `name`, `description`, `geometry`, `bbox`, `datetime`, `cloud_cover`, `gsd`, `properties`, `lifecycle_state`.

```http
POST /v1/op/items.update HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "item_id": "c71d...",
  "name": "Field 12 — corrected",
  "description": "Re-georeferenced 2026-05-18 capture",
  "collection_id": "3a9b..."
}
```

```python
updated = client.op(
    "items.update",
    item_id="c71d...",
    name="Field 12 — corrected",
    collection_id="3a9b...",
)
```

```typescript
const updated = await client.op('items.update', {
	item_id: 'c71d...',
	name: 'Field 12 — corrected',
	collection_id: '3a9b...'
});
```

Setting `collection_id` on an item is exactly how you **move it between collections** — there is no separate add/remove operation. Set it to `null` to remove the item from its collection without deleting it. Updating requires `editor`/`admin` on the project.

### Duplicating

`items.duplicate` copies an item, its assets (re-pointing at the same underlying files — no storage duplication), and its visualization profiles. The copy is recorded in lineage as derived from the source, so `items.lineage` will show the relationship. You can retarget the copy with `new_name`, `target_collection_id`, and `target_project_id`.

```python
copy = client.op(
    "items.duplicate",
    item_id="c71d...",
    new_name="Field 12 — working copy",
    target_collection_id="3a9b...",
)
```

## Working with collections

A collection is a lightweight grouping with display metadata: `name`, optional `description`, `color`, a `show_on_map` flag, and a `layer_order` integer that controls draw order on the map. Create one, then assign items to it via `collection_id` on `items.create` or `items.update`.

```http
POST /v1/op/collections.create HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{
  "project_id": "8f2c1e4a-...",
  "name": "May 2026 survey",
  "color": "#2e7d32",
  "show_on_map": true,
  "layer_order": 10
}
```

```python
collection = client.op(
    "collections.create",
    project_id=PROJECT_ID,
    name="May 2026 survey",
    color="#2e7d32",
)

# list with live item counts
for c in client.op("collections.list", project_id=PROJECT_ID):
    print(c["name"], c["item_count"])
```

```typescript
const collection = await client.op('collections.create', {
	project_id: PROJECT_ID,
	name: 'May 2026 survey',
	color: '#2e7d32'
});

const collections = await client.op('collections.list', { project_id: PROJECT_ID });
```

`collections.list` returns each collection with a live `item_count`. `collections.get` additionally enforces a belongs-to-project guard: a `collection_id` from a different project returns `404`, not someone else's data. `collections.update` accepts the same fields as create (all optional) and requires `editor`/`admin`. To list a collection's contents, use `items.list_by_collection` (or `items.list` with `collection_id`).

## Accessing assets

An item's files are its assets. List them with `items.list_assets`, which returns the bare asset array (each entry carries its `key`, `media_type`, `href`, and `file_size_bytes`). The primary file lives under the `data` key.

```python
assets = client.op("items.list_assets", item_id="c71d...")
data_asset = next(a for a in assets if a["key"] == "data")
```

To actually download a file, call `items.asset.download`. It validates item access, checks your subscription is in good standing, runs egress rate limits, and returns a short-lived signed URL (valid 15 minutes) — it does **not** stream bytes through the API.

```http
POST /v1/op/items.asset.download HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{ "item_id": "c71d...", "asset_id": "a55e..." }
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "url": "https://storage.googleapis.com/geopera-...?X-Goog-Signature=...",
  "expires_in_seconds": 900,
  "asset_id": "a55e...",
  "size_bytes": 41857332,
  "media_type": "image/tiff; application=geotiff; profile=cloud-optimized"
}
```

```python
dl = client.op("items.asset.download", item_id="c71d...", asset_id="a55e...")

import httpx
with httpx.stream("GET", dl["url"]) as r:
    with open("scene.tif", "wb") as f:
        for chunk in r.iter_bytes():
            f.write(chunk)
```

Because this operation has the `share_export` side effect, it is egress-tracked and subject to per-asset rate limits and a per-organization bandwidth circuit breaker. A `429` means you have hit a download limit; a `402` means the organization's subscription is past due. See [Rate limits](/api-reference/rate-limits) and [Errors](/api-reference/errors).

## Soft delete and expiry

Deletes on the platform are **soft**: the row is flagged, not erased, and every read operation filters out flagged rows automatically.

- **`items.delete`** sets `deleted_at`, transitions the item's `lifecycle_state`, and fires storage-billing decrements per asset. It requires `editor`/`admin` on the item's project. Deleting an already-deleted item returns `404`.
- **`assets.delete`** removes one file from an item. Two guards apply: the asset must belong to the named item (`403` otherwise), and you cannot delete the last remaining primary `data` asset (`400`).
- **`collections.delete`** soft-deletes the collection and **unlinks** its items — the items survive with `collection_id` set back to `null`. This is a project `admin`/`owner` action.

```http
POST /v1/op/items.delete HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json

{ "item_id": "c71d..." }
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{ "ok": true, "id": "c71d..." }
```

### Lifecycle states

Items carry a `lifecycle_state` that the platform advances over time. On free tiers an item moves `active → expiring → expired → deleted` as it approaches and passes its retention window; on paid tiers it can move `active → archived` instead. The `expires_at` timestamp tells you when the next transition is due. The retention machinery that advances items through these states runs internally on the platform — client tokens do not trigger it.

Once an item reaches `expired` (or `deleted`), attempting `items.asset.download` returns a retention error (`410`) — the imagery stops being served while the row and underlying file still exist. An `expiring` item is only a warning state and still downloads normally. Restore an expired item by upgrading the organization's plan, which re-activates it.

## Worked example: organize a delivery

Group a freshly delivered batch into a collection, tidy the metadata, and confirm the result.

```python
from geopera import Geopera

client = Geopera(api_key="gpra_your_api_key")
PROJECT_ID = "8f2c1e4a-..."

# 1. Create a collection for the delivery.
collection = client.op(
    "collections.create",
    project_id=PROJECT_ID,
    name="May 2026 aerial survey",
    color="#2e7d32",
    show_on_map=True,
    layer_order=10,
)
cid = collection["id"]

# 2. Find the uncollected items from this project and assign them.
loose = client.op("items.list", project_id=PROJECT_ID, uncollected=True, limit=200)
for item in loose["items"]:
    client.op(
        "items.update",
        item_id=item["id"],
        collection_id=cid,
        name=item["name"].strip(),
    )

# 3. Verify the collection now reports the expected count.
got = client.op("collections.get", project_id=PROJECT_ID, collection_id=cid)
print(got["name"], "now holds", got["item_count"], "items")

# 4. Pull a signed download URL for the first item's primary asset.
contents = client.op("items.list_by_collection", collection_id=cid, limit=1)
first = contents["items"][0]
assets = client.op("items.list_assets", item_id=first["id"])
data = next(a for a in assets if a["key"] == "data")
dl = client.op("items.asset.download", item_id=first["id"], asset_id=data["id"])
print("download:", dl["url"], "expires in", dl["expires_in_seconds"], "s")
```

## Gotchas

- **No REST verbs.** Listing, getting, and deleting are encoded in the operation name; everything is `POST /v1/op/{id}` with a body. Do not look for `GET /items/{id}`.
- **Soft delete is the only delete.** Deleted rows disappear from reads but are recoverable server-side; design idempotent flows accordingly. A second `items.delete` on the same id returns `404`.
- **Clearing vs. omitting.** On `items.update`, sending `null` clears a field; omitting it leaves the field as-is. To unassign an item from its collection, send `"collection_id": null`.
- **Download URLs are short-lived.** Signed URLs expire after 15 minutes (`expires_in_seconds: 900`). Request one per download attempt rather than caching it.
- **Deleting a collection keeps its items.** Items are unlinked (`collection_id` → `null`), never deleted, when their collection is removed.
- **Org search needs an org.** `items.search_org` returns an empty `FeatureCollection` (not an error) for a principal with no organization.

## Related

- [Operations](/api-reference/operations) — the `POST /v1/op/{id}` invocation model.
- [Pagination](/api-reference/pagination) — the `limit`/`offset`/`total` envelope used by list and search.
- [Scopes](/api-reference/scopes) — the read/write/admin scopes these operations require.
- [Errors](/api-reference/errors) — the problem+json shape behind every `4xx`.
- [Rate limits](/api-reference/rate-limits) — egress limits that gate `items.asset.download`.
