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:
Authenticate
Load Data
Create a Collection (POST)
Create Items (POST)
Update an Item (PUT)
Bulk ingest Items (POST
bulk_items)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:
Retrieve the current Item with
GETModify the desired fields (e.g. metadata or assets)
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 Itemidto the full Item JSONmethod—"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.