Customer-owned STAC ingestion and management

This notebook demonstrates how a customer can manage STAC entities in a protected, customer-specific STAC endpoint.

Covered steps:

  1. Authenticate

  2. Load Data

  3. Create a Collection (POST)

  4. Create Items (POST)

  5. Update an Item (PUT)

  6. Bulk ingest Items (POST bulk_items)

  7. Delete STAC entities

Prerequisites:

  • Access to a customer-specific STAC API endpoint

  • A valid access token (temporarily provided via environment variable)

try:
    import eodc_connect
except ImportError:
    !pip install eodc-connect --upgrade --force-reinstall

1. Authentication

Enter the username of your eodc account here, usually the email address you used for registration. After running the next cell, a prompt will open and ask you to enter your password.

from getpass import getpass
from eodc_connect.auth import EODCConnection
import requests
import json

# Prompt for credentials
username = input("Enter your eodc username: ")
password = getpass("Enter your eodc password: ")

# Create connection
conn = EODCConnection(username=username, password=password)

# Get token
token = conn.get_access_token()

# Request 
resp = requests.get(
    "https://cstac.discovery.eodc.eu",
    headers={"Authorization": f"Bearer {token}"}
)

print(json.dumps(resp.json(), indent=4))
print("Authentication configured.")
{
    "type": "Catalog",
    "id": "stac-fastapi",
    "title": "stac-fastapi",
    "description": "stac-fastapi",
    "stac_version": "1.0.0",
    "conformsTo": [
        "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2",
        "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json",
        "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
        "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query",
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30",
        "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter",
        "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
        "https://api.stacspec.org/v1.0.0-rc.1/collection-search",
        "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields",
        "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter",
        "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query",
        "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort",
        "https://api.stacspec.org/v1.0.0-rc.2/item-search#filter",
        "https://api.stacspec.org/v1.0.0/collections",
        "https://api.stacspec.org/v1.0.0/collections/extensions/transaction",
        "https://api.stacspec.org/v1.0.0/core",
        "https://api.stacspec.org/v1.0.0/item-search",
        "https://api.stacspec.org/v1.0.0/item-search#fields",
        "https://api.stacspec.org/v1.0.0/item-search#query",
        "https://api.stacspec.org/v1.0.0/item-search#sort",
        "https://api.stacspec.org/v1.0.0/ogcapi-features",
        "https://api.stacspec.org/v1.0.0/ogcapi-features#fields",
        "https://api.stacspec.org/v1.0.0/ogcapi-features#query",
        "https://api.stacspec.org/v1.0.0/ogcapi-features#sort",
        "https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction"
    ],
    "links": [
        {
            "rel": "self",
            "type": "application/json",
            "title": "This document",
            "href": "https://cstac.discovery.eodc.eu/"
        },
        {
            "rel": "root",
            "type": "application/json",
            "title": "Root",
            "href": "https://cstac.discovery.eodc.eu/"
        },
        {
            "rel": "data",
            "type": "application/json",
            "title": "Collections available for this Catalog",
            "href": "https://cstac.discovery.eodc.eu/collections"
        },
        {
            "rel": "conformance",
            "type": "application/json",
            "title": "STAC/OGC conformance classes implemented by this server",
            "href": "https://cstac.discovery.eodc.eu/conformance"
        },
        {
            "rel": "search",
            "type": "application/geo+json",
            "title": "STAC search [GET]",
            "href": "https://cstac.discovery.eodc.eu/search",
            "method": "GET"
        },
        {
            "rel": "search",
            "type": "application/geo+json",
            "title": "STAC search [POST]",
            "href": "https://cstac.discovery.eodc.eu/search",
            "method": "POST"
        },
        {
            "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables",
            "type": "application/schema+json",
            "title": "Queryables available for this Catalog",
            "href": "https://cstac.discovery.eodc.eu/queryables",
            "method": "GET"
        },
        {
            "rel": "service-desc",
            "type": "application/vnd.oai.openapi+json;version=3.0",
            "title": "OpenAPI service description",
            "href": "https://cstac.discovery.eodc.eu/api"
        },
        {
            "rel": "service-doc",
            "type": "text/html",
            "title": "OpenAPI service documentation",
            "href": "https://cstac.discovery.eodc.eu/api.html"
        }
    ],
    "stac_extensions": []
}
Authentication configured.

2. Load sample data

We load a ready-to-ingest Collection and Items from sample_data/, available in the eodc-examples GitHub repository.

Make sure that your STAC metadata contains all required fields. In particular, the access field defines who can see the data in the catalogue.

Access control metadata

When creating STAC Collections or Items, it is important to include the access metadata field. This field defines who is allowed to view the data in the catalogue.

Example: make the data visible to all members of an organisation:

{
  "access": {
    "visibility": "organisation",
    "allowed_users": [],
    "allowed_organisations": ["eodc"]
  }
}

Example: restrict access to a specific user:

{
  "access": {
    "visibility": "user",
    "allowed_users": ["john.smith@example.org", "jane.smith@example.org"],
    "allowed_organisations": []
  }
}

If this field is missing or configured incorrectly, the data may not be visible to other users in the catalogue. You also have to have a unique collection id.

from pathlib import Path
import json

collection = json.loads(Path("sample_data/collection_customer_demo.json").read_text())
items = [
    json.loads(path.read_text())
    for path in sorted(Path("sample_data/items").glob("item_*.json"))
]

print("Loaded collection id:", collection["id"])
print("Loaded items:", [item["id"] for item in items])

collection_id = collection["id"]
Loaded collection id: customer-demo
Loaded items: ['item-001', 'item-002', 'item-003']

3. Create the Collection (POST)

We create the Collection via POST /collections.

TIMEOUT = 30

WRITE_BASE = "https://cstac.discovery.eodc.eu"

HEADERS = {
    "Authorization": f"Bearer {token}",
    "Accept": "application/json",
    "Content-Type": "application/json",
}

def request_json(method, url, **kwargs):
    r = requests.request(
        method,
        url,
        headers=HEADERS,
        timeout=TIMEOUT,
        **kwargs
    )

    return r, r.json()
url = f"{WRITE_BASE}/collections"
r, data = request_json("POST", url, json=collection)

r.raise_for_status()
print(f"Create Collection: {collection['id']}")
print(url)
Create Collection: customer-demo
https://cstac.discovery.eodc.eu/collections

4. Create Items (POST)

We add Items to the Collection via: POST /collections/{collectionId}/items

items_url = f"{WRITE_BASE}/collections/{collection_id}/items"

created_items = []

for item in items:
    r, data = request_json("POST", items_url, json=item)
    r.raise_for_status()
    created_items.append(item["id"])

print("Processed items:", created_items)
print(items_url)
Processed items: ['item-001', 'item-002', 'item-003']
https://cstac.discovery.eodc.eu/collections/customer-demo/items

5. Update an Item (PUT)

Existing STAC Items can be updated using a PUT request.

The typical workflow is:

  1. Retrieve the current Item with GET

  2. Modify the desired fields (e.g. metadata or assets)

  3. Replace Item via PUT

In this example we update the updated timestamp in the Item metadata.

from datetime import datetime, timezone

def now_utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


target_item_id = items[0]["id"]
item_url = f"{WRITE_BASE}/collections/{collection_id}/items/{target_item_id}"

# 1) Fetch current item
r, current = request_json("GET", item_url)
r.raise_for_status()

# 2) Modify metadata
current.setdefault("properties", {})
current["properties"]["updated"] = now_utc_iso()

# 3) Replace item with updated version
r, data = request_json("PUT", item_url, json=current)
r.raise_for_status()

print(f"Item '{target_item_id}' updated successfully")
print("Updated timestamp:")
print(current["properties"]["updated"])
Item 'item-001' updated successfully
Updated timestamp:
2026-10-05T11:42:20.594796Z

Optionally, the same approach can be used to modify the assets dictionary of an Item. For example, you could add or update an Asset describing additional metadata or data files associated with the Item.

# Fetch item again
r, current = request_json("GET", item_url)
r.raise_for_status()

current.setdefault("assets", {})

# Add a new asset named "metadata"
current["assets"]["metadata"] = {
    "href": f"https://example.com/{target_item_id}.json",
    "type": "application/json",
    "title": "Metadata example",
    "roles": ["metadata"]
}

# Update item with new asset
r, data = request_json("PUT", item_url, json=current)
r.raise_for_status()

print(f"Asset added successfully to {target_item_id}")
print("Assets:")
print(json.dumps(current["assets"], indent=2))
Asset added successfully to item-001
Assets:
{
  "metadata": {
    "href": "https://example.com/item-001.json",
    "type": "application/json",
    "title": "Metadata example",
    "roles": [
      "metadata"
    ]
  }
}

6. Bulk ingest Items

Posting Items one by one (step 4) is fine for a handful of Items, but it means one HTTP request per Item. For larger batches, the API offers a bulk transactions endpoint that writes many Items to the same Collection in a single request:

POST /collections/{collectionId}/bulk_items

The request body is an object with two fields:

  • items — a dictionary mapping each Item id to the full Item JSON

  • method — "insert" (default; fails if any Item already exists) or "upsert" (inserts new Items and replaces existing ones)

{
  "items": {
    "item-001": { "type": "Feature", "id": "item-001", "collection": "my-collection", ... },
    "item-002": { "type": "Feature", "id": "item-002", "collection": "my-collection", ... }
  },
  "method": "upsert"
}

All Items must belong to the Collection in the URL and include the same access metadata as single Items. The bulk endpoint is an optional extension of stac-fastapi. If the server does not enable it, the request returns 404 or 405, and you should fall back to the per-Item POST from step 4.

The Items from step 4 already exist, so we use "upsert" here. With "insert" the request would fail because of duplicate Item ids. We also update each Item’s updated timestamp so the upsert visibly changes something.

bulk_url = f"{WRITE_BASE}/collections/{collection_id}/bulk_items"

bulk_items = {}
for item in items:
    item_copy = json.loads(json.dumps(item))  # deep copy, keep the originals untouched
    item_copy["collection"] = collection_id
    item_copy.setdefault("properties", {})["updated"] = now_utc_iso()
    bulk_items[item_copy["id"]] = item_copy

payload = {"items": bulk_items, "method": "upsert"}

r = requests.post(bulk_url, headers=HEADERS, json=payload, timeout=TIMEOUT)

if r.status_code in (404, 405):
    print("Bulk ingest is not enabled on this endpoint, use the per-Item POST from step 4 instead.")
else:
    r.raise_for_status()
    print(f"Bulk ingested {len(bulk_items)} items into '{collection_id}'")
    print("Response:", r.text)
print(bulk_url)
Bulk ingested 3 items into 'customer-demo'
Response: "Successfully upserted 3 items."
https://cstac.discovery.eodc.eu/collections/customer-demo/bulk_items

We check the result by listing the Items in the Collection and their updated timestamps:

r, data = request_json("GET", f"{WRITE_BASE}/collections/{collection_id}/items", params={"limit": 100})
r.raise_for_status()

for feature in data.get("features", []):
    print("-", feature["id"], feature.get("properties", {}).get("updated"))
- item-003 2026-10-05T11:42:32.755701Z
- item-002 2026-10-05T11:42:32.755664Z
- item-001 2026-10-05T11:42:32.755600Z

Alternative: pypgstac load items (database-level)

Operators with direct access to the pgstac PostgreSQL database can load Items with the pypgstac CLI instead of the API. It reads newline-delimited JSON (one Item per line) and is the fastest way to load large volumes:

pip install "pypgstac[psycopg]"

# Convert Items to newline-delimited JSON
jq -c . sample_data/items/item_*.json > items.ndjson

# Load into pgstac (methods: insert, ignore, upsert, delsert)
pypgstac load items items.ndjson \
    --dsn postgresql://user:password@host:5432/postgis \
    --method upsert

pypgstac connects to the database directly and bypasses the API, including its authentication and authorisation. Customers of the eodc customer STAC endpoint do not get database access, so for them the bulk_items endpoint above is the way to ingest in bulk.

7. Delete Collection (cleanup)

We can have a look at all available collections before and after deletion.

print("BEFORE deletion:")
r, data = request_json("GET", f"{WRITE_BASE}/collections")
for col in data.get("collections", []):
    print("-", col["id"])
BEFORE deletion:
- ARA_full_ensemble
- ara-v1-1d-2500m
- ara-v1-1h-2500m
- customer-demo
- noaa-emergency-response
del_col_url = f"{WRITE_BASE}/collections/{collection_id}"
r, data = request_json("DELETE", del_col_url)
r.raise_for_status()

print(f"Deleted collection: {collection_id}")
Deleted collection: customer-demo
print("AFTER deletion:")
r, data = request_json("GET", f"{WRITE_BASE}/collections")
for col in data.get("collections", []):
    print("-", col["id"])
AFTER deletion:
- ARA_full_ensemble
- ara-v1-1d-2500m
- ara-v1-1h-2500m
- noaa-emergency-response

8. Common mistakes

Missing access metadata — if the access field is missing, the dataset may not be visible to other users.

Invalid STAC structure — make sure the metadata follows the STAC specification. The stac-validator tool can help catch issues before ingestion:

stac-validator collection.json

Incorrect asset links — assets must point to valid and accessible data files.

9. Troubleshooting

Item creation fails — check that the collection exists, item IDs are unique, and the metadata is valid JSON.

Data not visible in the catalogue — check the access settings, organisation name, and permissions.

Validation errors — run a STAC validator before ingestion.

10. Support

If you encounter problems publishing STAC data at eodc, please contact support@eodc.eu.