Quickstart
This page works through a small example on a running broker. It puts three of Ljubljana's BicikeLJ bike-share stations on the map, finds a bike near Prešeren Square, records the morning rush and reads it back, and gets a notification when a station runs empty. It assumes a broker from Getting started on http://localhost:1056.
The stations follow the Smart Data Models BikeHireDockingStation, a data model many NGSI-LD deployments already use. Andromeda is compatible with Smart Data Models. Entities written to a published model, with its published @context, work as they are, with no conversion. Station locations are from OpenStreetMap.
Every request is shown four ways, as the raw HTTP request, with curl, with the Rust ngsi-ld crate, and with Python and requests. Pick one and the whole page follows, and so do the guides.
Naming the vocabulary
NGSI-LD names every type and attribute with a full IRI. A JSON-LD @context maps the short names you write, such as availableBikeNumber, to those IRIs. Smart Data Models publishes one for its transport models, and every request on this page names it in a Link header.
- http
- curl
- rust
- python
Each request below carries this header:
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
Keep the header in a shell variable:
CONTEXT='<https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"'
The Rust examples run in one async function that returns anyhow::Result<()>, with these dependencies:
[dependencies]
anyhow = "1.0.104"
iri-rs = { version = "4.1.0", features = ["static"] }
ngsi-ld = "2.0.0"
tokio = { version = "1.54.1", features = ["macros", "rt-multi-thread"] }
url = "2.5.8"
Give the client the @context once, and it sends the header with every request:
use anyhow::Context as _;
use iri_rs::{IriRefBuf, iri};
use ngsi_ld::{
client::{Client, ReqwestTransport},
http::operation::ResultsCount,
model::{
Context, ContextEntry, DateTime, Entity, EntityFragment, EntityId, ErrorType, InlineContext,
Property, Representation, Tenant,
evolution::AggregationMethod,
geometry::{Position, Shape},
},
query::{geoquery, projection, q, temporal_query::AggregationMethods},
subscription::{Endpoint, NotificationParams, Subscription, SubscriptionId},
};
use url::Url;
const SMART_DATA_MODELS: &str =
"https://smart-data-models.github.io/dataModel.Transportation/context.jsonld";
let client = Client::builder()
.base_url(Url::parse("http://localhost:1056/ngsi-ld/v1/")?)
.context(IriRefBuf::new(SMART_DATA_MODELS)?)
.transport(ReqwestTransport::try_default()?)
.build()?;
Keep the address and the header together:
import requests
BASE = "http://localhost:1056/ngsi-ld/v1"
CONTEXT = '<https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"'
HEADERS = {"Link": CONTEXT}
The broker fetches the @context from that URL the first time it sees it and keeps a copy, so it needs to reach the internet once. JSON-LD @context explains how names are expanded.
Creating the stations
Put the three stations on the map in one batch, with the bikes each one has at eight in the morning:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entityOperations/create
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
[
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"name": "Prešernov trg",
"status": "empty",
"totalSlotNumber": 20,
"availableBikeNumber": {
"value": 0,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 20,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
}
},
{
"id": "urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"type": "BikeHireDockingStation",
"name": "Kongresni trg",
"status": "working",
"totalSlotNumber": 20,
"availableBikeNumber": {
"value": 7,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 13,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.50445,
46.05042
]
}
},
{
"id": "urn:ngsi-ld:BikeHireDockingStation:trg-of",
"type": "BikeHireDockingStation",
"name": "Trg OF",
"status": "working",
"totalSlotNumber": 25,
"availableBikeNumber": {
"value": 12,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 13,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.51026,
46.05732
]
}
}
]
curl -i http://localhost:1056/ngsi-ld/v1/entityOperations/create \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-d '[
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"name": "Prešernov trg",
"status": "empty",
"totalSlotNumber": 20,
"availableBikeNumber": {
"value": 0,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 20,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
}
},
{
"id": "urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"type": "BikeHireDockingStation",
"name": "Kongresni trg",
"status": "working",
"totalSlotNumber": 20,
"availableBikeNumber": {
"value": 7,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 13,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.50445,
46.05042
]
}
},
{
"id": "urn:ngsi-ld:BikeHireDockingStation:trg-of",
"type": "BikeHireDockingStation",
"name": "Trg OF",
"status": "working",
"totalSlotNumber": 25,
"availableBikeNumber": {
"value": 12,
"observedAt": "2026-11-06T08:00:00Z"
},
"freeSlotNumber": {
"value": 13,
"observedAt": "2026-11-06T08:00:00Z"
},
"location": {
"type": "Point",
"coordinates": [
14.51026,
46.05732
]
}
}
]'
let at_eight: DateTime = "2026-11-06T08:00:00Z".parse()?;
let station = |id: &str, name: &str, status: &str, slots: u32, bikes: u32, position: Position| {
Entity::builder()
.id(id)
.entity_type("BikeHireDockingStation")
.property("name", name)
.property("status", status)
.property("totalSlotNumber", slots)
.property("availableBikeNumber", Property::builder(bikes).observed_at(at_eight))
.property("freeSlotNumber", Property::builder(slots - bikes).observed_at(at_eight))
.geo_property("location", Shape::Point(position))
.build()
};
let stations = [
station(
"urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"Prešernov trg",
"empty",
20,
0,
Position::try_new(14.50673, 46.05137)?,
)?,
station(
"urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"Kongresni trg",
"working",
20,
7,
Position::try_new(14.50445, 46.05042)?,
)?,
station(
"urn:ngsi-ld:BikeHireDockingStation:trg-of",
"Trg OF",
"working",
25,
12,
Position::try_new(14.51026, 46.05732)?,
)?,
];
client.entities().batch_create(&stations).send().await?;
def station(id, name, status, slots, bikes, longitude, latitude):
return {
"id": id,
"type": "BikeHireDockingStation",
"name": name,
"status": status,
"totalSlotNumber": slots,
"availableBikeNumber": {"value": bikes, "observedAt": "2026-11-06T08:00:00Z"},
"freeSlotNumber": {"value": slots - bikes, "observedAt": "2026-11-06T08:00:00Z"},
"location": {"type": "Point", "coordinates": [longitude, latitude]},
}
response = requests.post(
f"{BASE}/entityOperations/create",
headers=HEADERS,
json=[
station("urn:ngsi-ld:BikeHireDockingStation:presernov-trg", "Prešernov trg", "empty", 20, 0, 14.50673, 46.05137),
station("urn:ngsi-ld:BikeHireDockingStation:kongresni-trg", "Kongresni trg", "working", 20, 7, 14.50445, 46.05042),
station("urn:ngsi-ld:BikeHireDockingStation:trg-of", "Trg OF", "working", 25, 12, 14.51026, 46.05732),
],
)
assert response.status_code == 201
The answer is 201 Created, with the identifiers of the new stations:
[
"urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"urn:ngsi-ld:BikeHireDockingStation:trg-of"
]
The body uses the concise form. A plain value such as "totalSlotNumber": 20 is a Property, an object with a value is a Property with more to say about it, here when the count was observed, and a GeoJSON geometry is a GeoProperty. Values lists every kind of attribute. An id must be a URI, and urn:ngsi-ld:<type>:<name> is the usual form.
Reading one back
- http
- curl
- rust
- python
GET /ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg?options=keyValues
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -G http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg \
-H "Link: $CONTEXT" \
--data-urlencode 'options=keyValues'
let presernov = EntityId::from(iri!("urn:ngsi-ld:BikeHireDockingStation:presernov-trg"));
let key_values = client.entities().retrieve_simplified(&presernov).send().await?.into_body();
println!("{}", key_values.render());
PRESERNOV = f"{BASE}/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg"
response = requests.get(PRESERNOV, headers=HEADERS, params={"options": "keyValues"})
assert response.status_code == 200
print(response.json())
keyValues asks for the values alone:
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"availableBikeNumber": 0,
"freeSlotNumber": 20,
"name": "Prešernov trg",
"status": "empty",
"totalSlotNumber": 20,
"location": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
}
}
Without it you get the normalized form, where each attribute says what kind it is and carries its observedAt. In Rust that is retrieve instead of retrieve_simplified. Leave the Link header out and the names come back as full IRIs, such as https://smartdatamodels.org/dataModel.Transportation/availableBikeNumber, because the broker can only shorten them with the @context you name.
Finding a bike nearby
Where can you get a bike within 400 m of Prešeren Square?
- http
- curl
- rust
- python
GET /ngsi-ld/v1/entities?type=BikeHireDockingStation&q=availableBikeNumber>0&georel=near;maxDistance==400&geometry=Point&coordinates=[14.50602,46.05137]&options=keyValues&pick=name,availableBikeNumber
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -G http://localhost:1056/ngsi-ld/v1/entities \
-H "Link: $CONTEXT" \
--data-urlencode 'type=BikeHireDockingStation' \
--data-urlencode 'q=availableBikeNumber>0' \
--data-urlencode 'georel=near;maxDistance==400' \
--data-urlencode 'geometry=Point' \
--data-urlencode 'coordinates=[14.50602,46.05137]' \
--data-urlencode 'options=keyValues' \
--data-urlencode 'pick=name,availableBikeNumber'
let presernov_square = Shape::Point(Position::try_new(14.50602, 46.05137)?);
let nearby = client
.entities()
.query_projected_simplified()
.entity_type("BikeHireDockingStation")
.q(q::attr("availableBikeNumber").gt(0))
.geo_q(geoquery::near_max(400, presernov_square))
.pick(projection::attributes(["name", "availableBikeNumber"]))
.send()
.await?;
for station in nearby.items() {
println!("{}", station.render());
}
response = requests.get(
f"{BASE}/entities",
headers=HEADERS,
params={
"type": "BikeHireDockingStation",
"q": "availableBikeNumber>0",
"georel": "near;maxDistance==400",
"geometry": "Point",
"coordinates": "[14.50602,46.05137]",
"options": "keyValues",
"pick": "name,availableBikeNumber",
},
)
assert response.status_code == 200
print(response.json())
[
{
"availableBikeNumber": 7,
"name": "Kongresni trg"
}
]
Prešernov trg is closer, but it is empty, and Trg OF has bikes but is 700 m away, by the railway station. q filters on what the values say, the georel, geometry and coordinates together filter by place, and pick keeps only the attributes you name. Query language and Geometry and geoqueries have the details.
Results come in pages. Add count=true and the total is in the NGSILD-Results-Count header, and with limit=0 as well you get the total alone:
- http
- curl
- rust
- python
GET /ngsi-ld/v1/entities?type=BikeHireDockingStation&count=true&limit=0
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -i -G http://localhost:1056/ngsi-ld/v1/entities \
-H "Link: $CONTEXT" \
--data-urlencode 'type=BikeHireDockingStation' \
--data-urlencode 'count=true' \
--data-urlencode 'limit=0'
let counted = client
.entities()
.query()
.entity_type("BikeHireDockingStation")
.count(ResultsCount::Include)
.limit(0)
.send()
.await?;
println!("{:?}", counted.total());
response = requests.get(
f"{BASE}/entities",
headers=HEADERS,
params={"type": "BikeHireDockingStation", "count": "true", "limit": "0"},
)
assert response.status_code == 200
print(response.headers["NGSILD-Results-Count"])
The answer is an empty array with NGSILD-Results-Count: 3. Pagination explains how to walk the pages of a bigger answer.
Putting them on a map
Ask for application/geo+json and the answer is a GeoJSON FeatureCollection that a map library can draw as it is:
- http
- curl
- rust
- python
GET /ngsi-ld/v1/entities?type=BikeHireDockingStation&options=keyValues&pick=name,availableBikeNumber,location
Accept: application/geo+json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -G http://localhost:1056/ngsi-ld/v1/entities \
-H "Link: $CONTEXT" \
-H 'Accept: application/geo+json' \
--data-urlencode 'type=BikeHireDockingStation' \
--data-urlencode 'options=keyValues' \
--data-urlencode 'pick=name,availableBikeNumber,location'
let map = client
.entities()
.query_geo_json_simplified()
.entity_type("BikeHireDockingStation")
.send()
.await?;
for feature in map.items() {
println!("{}", feature.render());
}
This prints one feature per line, with every attribute of the station.
response = requests.get(
f"{BASE}/entities",
headers={**HEADERS, "Accept": "application/geo+json"},
params={
"type": "BikeHireDockingStation",
"options": "keyValues",
"pick": "name,availableBikeNumber,location",
},
)
assert response.status_code == 200
print(response.json())
Each station is a feature, with its location as the geometry and its attributes as the properties:
{
"@context": [
"https://smart-data-models.github.io/dataModel.Transportation/context.jsonld",
"https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
],
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
14.50445,
46.05042
]
},
"properties": {
"availableBikeNumber": 7,
"name": "Kongresni trg",
"location": {
"type": "Point",
"coordinates": [
14.50445,
46.05042
]
}
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
},
"properties": {
"availableBikeNumber": 0,
"name": "Prešernov trg",
"location": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
}
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
14.51026,
46.05732
]
},
"properties": {
"availableBikeNumber": 12,
"name": "Trg OF",
"location": {
"type": "Point",
"coordinates": [
14.51026,
46.05732
]
}
}
}
]
}
Keep location in pick, because the geometry of each feature is taken from the attributes the answer holds. Geoqueries covers the other shapes and relations.
Recording a change
At a quarter past eight, three riders bring bikes back to Prešernov trg. Change the attributes that moved and leave the rest alone:
- http
- curl
- rust
- python
PATCH /ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg/attrs
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"status": "working",
"availableBikeNumber": {
"value": 3,
"observedAt": "2026-11-06T08:15:00Z"
},
"freeSlotNumber": {
"value": 17,
"observedAt": "2026-11-06T08:15:00Z"
}
}
curl -i -X PATCH http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg/attrs \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-d '{
"status": "working",
"availableBikeNumber": {
"value": 3,
"observedAt": "2026-11-06T08:15:00Z"
},
"freeSlotNumber": {
"value": 17,
"observedAt": "2026-11-06T08:15:00Z"
}
}'
let at_quarter_past: DateTime = "2026-11-06T08:15:00Z".parse()?;
let returned = EntityFragment::builder()
.property("status", "working")
.property("availableBikeNumber", Property::builder(3).observed_at(at_quarter_past))
.property("freeSlotNumber", Property::builder(17).observed_at(at_quarter_past))
.build()?;
client.entities().update_attributes(&presernov, &returned).send().await?;
response = requests.patch(
f"{PRESERNOV}/attrs",
headers=HEADERS,
json={
"status": "working",
"availableBikeNumber": {"value": 3, "observedAt": "2026-11-06T08:15:00Z"},
"freeSlotNumber": {"value": 17, "observedAt": "2026-11-06T08:15:00Z"},
},
)
assert response.status_code == 204
The answer is 204 No Content. The guide to entity writes covers the other ways to write, such as adding attributes, merging and replacing.
Reading the history
Every change is kept, so the station's morning is there to read back:
- http
- curl
- rust
- python
GET /ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg?attrs=availableBikeNumber&options=temporalValues
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -G http://localhost:1056/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg \
-H "Link: $CONTEXT" \
--data-urlencode 'attrs=availableBikeNumber' \
--data-urlencode 'options=temporalValues'
let history = client
.temporal()
.retrieve_values(&presernov)
.attrs(vec!["availableBikeNumber".parse()?])
.send()
.await?
.into_body();
println!("{}", history.render());
HISTORY = f"{BASE}/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg"
response = requests.get(
HISTORY,
headers=HEADERS,
params={"attrs": "availableBikeNumber", "options": "temporalValues"},
)
assert response.status_code == 200
print(response.json())
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"availableBikeNumber": {
"type": "Property",
"values": [
[
0,
"2026-11-06T08:00:00Z"
],
[
3,
"2026-11-06T08:15:00Z"
]
]
}
}
Each value is paired with its observedAt. Add lastN=1 for only the latest one. To see the morning in hours rather than readings, ask for aggregated values instead:
- http
- curl
- rust
- python
GET /ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg?attrs=availableBikeNumber&options=aggregatedValues&aggrMethods=min,max,avg&aggrPeriodDuration=PT1H
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
curl -G http://localhost:1056/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg \
-H "Link: $CONTEXT" \
--data-urlencode 'attrs=availableBikeNumber' \
--data-urlencode 'options=aggregatedValues' \
--data-urlencode 'aggrMethods=min,max,avg' \
--data-urlencode 'aggrPeriodDuration=PT1H'
let methods = AggregationMethods::new([AggregationMethod::Min, AggregationMethod::Max, AggregationMethod::Avg])
.context("at least one aggregation method")?;
let hourly = client
.temporal()
.retrieve_aggregated(&presernov)
.attrs(vec!["availableBikeNumber".parse()?])
.aggregation_methods(methods)
.aggregation_period("PT1H".parse()?)
.send()
.await?
.into_body();
println!("{}", hourly.render());
response = requests.get(
HISTORY,
headers=HEADERS,
params={
"attrs": "availableBikeNumber",
"options": "aggregatedValues",
"aggrMethods": "min,max,avg",
"aggrPeriodDuration": "PT1H",
},
)
assert response.status_code == 200
print(response.json())
Each value covers one hour, from its start to its end:
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"availableBikeNumber": {
"type": "Property",
"min": [
[
0,
"2026-11-06T08:00:00Z",
"2026-11-06T09:00:00Z"
]
],
"max": [
[
3,
"2026-11-06T08:00:00Z",
"2026-11-06T09:00:00Z"
]
],
"avg": [
[
1.5,
"2026-11-06T08:00:00Z",
"2026-11-06T09:00:00Z"
]
]
}
}
The history is written in the same transaction as the change, so it never falls behind the current state. Temporal queries covers time ranges and the other aggregation methods.
Hearing when a station runs empty
A subscription tells another system about changes as they happen, so it doesn't have to keep asking. It needs an endpoint the broker can reach. For a first try, run this small receiver in a second terminal. It prints each notification and answers 204:
python3 -c '
from http.server import BaseHTTPRequestHandler, HTTPServer
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
print(self.rfile.read(int(self.headers["Content-Length"])).decode(), flush=True)
self.send_response(204)
self.end_headers()
HTTPServer(("0.0.0.0", 8085), Receiver).serve_forever()
'
It listens on every interface, so that a broker in a container can reach it, and you can stop it with Ctrl+C once you are done. Then subscribe to stations whose bike count drops to zero:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/subscriptions
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"id": "urn:ngsi-ld:Subscription:empty-stations",
"type": "Subscription",
"entities": [
{
"type": "BikeHireDockingStation"
}
],
"watchedAttributes": [
"availableBikeNumber"
],
"q": "availableBikeNumber==0",
"notification": {
"attributes": [
"name",
"availableBikeNumber"
],
"format": "keyValues",
"endpoint": {
"uri": "http://host.docker.internal:8085/notify"
}
}
}
curl -i http://localhost:1056/ngsi-ld/v1/subscriptions \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-d '{
"id": "urn:ngsi-ld:Subscription:empty-stations",
"type": "Subscription",
"entities": [
{
"type": "BikeHireDockingStation"
}
],
"watchedAttributes": [
"availableBikeNumber"
],
"q": "availableBikeNumber==0",
"notification": {
"attributes": [
"name",
"availableBikeNumber"
],
"format": "keyValues",
"endpoint": {
"uri": "http://host.docker.internal:8085/notify"
}
}
}'
let endpoint = Endpoint::builder("http://host.docker.internal:8085/notify").build()?;
let empty_stations = SubscriptionId::from(iri!("urn:ngsi-ld:Subscription:empty-stations"));
let subscription = Subscription::builder()
.id(empty_stations.clone())
.entities(["BikeHireDockingStation"])
.watched_attributes(["availableBikeNumber"])
.q(q::attr("availableBikeNumber").eq(0).build()?)
.notification(
NotificationParams::builder(endpoint)
.attributes(["name", "availableBikeNumber"])
.format(Representation::Simplified)
.build()?,
)
.build()?;
client.subscriptions().create(&subscription).send().await?;
response = requests.post(
f"{BASE}/subscriptions",
headers=HEADERS,
json={
"id": "urn:ngsi-ld:Subscription:empty-stations",
"type": "Subscription",
"entities": [{"type": "BikeHireDockingStation"}],
"watchedAttributes": ["availableBikeNumber"],
"q": "availableBikeNumber==0",
"notification": {
"attributes": ["name", "availableBikeNumber"],
"format": "keyValues",
"endpoint": {"uri": "http://host.docker.internal:8085/notify"},
},
},
)
assert response.status_code == 201
host.docker.internal is how a container reaches the machine it runs on. Docker Desktop provides it. On Linux, add extra_hosts: ["host.docker.internal:host-gateway"] to the andromeda service. With a broker built from source, use http://127.0.0.1:8085/notify instead.
The answer is 201 Created. watchedAttributes says which changes count, q says which of them you want to hear about, and attributes and format shape what the notification carries. At half past eight the last bikes leave Kongresni trg:
- http
- curl
- rust
- python
PATCH /ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:kongresni-trg/attrs
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"status": "empty",
"availableBikeNumber": {
"value": 0,
"observedAt": "2026-11-06T08:30:00Z"
},
"freeSlotNumber": {
"value": 20,
"observedAt": "2026-11-06T08:30:00Z"
}
}
curl -i -X PATCH http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:kongresni-trg/attrs \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-d '{
"status": "empty",
"availableBikeNumber": {
"value": 0,
"observedAt": "2026-11-06T08:30:00Z"
},
"freeSlotNumber": {
"value": 20,
"observedAt": "2026-11-06T08:30:00Z"
}
}'
let half_past: DateTime = "2026-11-06T08:30:00Z".parse()?;
let kongresni = EntityId::from(iri!("urn:ngsi-ld:BikeHireDockingStation:kongresni-trg"));
let emptied = EntityFragment::builder()
.property("status", "empty")
.property("availableBikeNumber", Property::builder(0).observed_at(half_past))
.property("freeSlotNumber", Property::builder(20).observed_at(half_past))
.build()?;
client.entities().update_attributes(&kongresni, &emptied).send().await?;
response = requests.patch(
f"{BASE}/entities/urn:ngsi-ld:BikeHireDockingStation:kongresni-trg/attrs",
headers=HEADERS,
json={
"status": "empty",
"availableBikeNumber": {"value": 0, "observedAt": "2026-11-06T08:30:00Z"},
"freeSlotNumber": {"value": 20, "observedAt": "2026-11-06T08:30:00Z"},
},
)
assert response.status_code == 204
The receiver prints the notification:
{
"id": "urn:ngsi-ld:Notification:04d8773b-732b-441c-a670-15d0e7c3c17d",
"type": "Notification",
"subscriptionId": "urn:ngsi-ld:Subscription:empty-stations",
"notifiedAt": "2026-11-07T20:41:12.277716Z",
"data": [
{
"id": "urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"type": "BikeHireDockingStation",
"availableBikeNumber": 0,
"name": "Kongresni trg"
}
]
}
The change to Prešernov trg at a quarter past eight sent nothing, because it left the station with three bikes. Read the subscription back with GET /ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:empty-stations to see timesSent and lastSuccess. Subscriptions covers the other triggers, periodic notifications and MQTT delivery.
Adding a term of your own
Smart Data Models has no attribute for the cargo bikes some stations keep, so the operator adds a term of its own. A @context can combine the published one with your additions, and the broker can host it for you:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/jsonldContexts
Content-Type: application/json
{
"@context": [
"https://smart-data-models.github.io/dataModel.Transportation/context.jsonld",
{
"cargoBikeNumber": "https://example.org/bicikelj/cargoBikeNumber"
}
]
}
curl -i http://localhost:1056/ngsi-ld/v1/jsonldContexts \
-H 'Content-Type: application/json' \
-d '{
"@context": [
"https://smart-data-models.github.io/dataModel.Transportation/context.jsonld",
{
"cargoBikeNumber": "https://example.org/bicikelj/cargoBikeNumber"
}
]
}'
let bicikelj = Context::Multiple(vec![
ContextEntry::Reference(IriRefBuf::new(SMART_DATA_MODELS)?),
ContextEntry::Inline(
r#"{"cargoBikeNumber": "https://example.org/bicikelj/cargoBikeNumber"}"#.parse::<InlineContext>()?,
),
]);
let added = client.contexts().add(&bicikelj).send().await?;
let context_id = added.id().context("the broker names the stored @context")?;
let location = added.location.valid().context("the broker sends a Location")?;
let context_url = client.endpoint().base_url().join(location.as_str())?;
response = requests.post(
f"{BASE}/jsonldContexts",
json={
"@context": [
"https://smart-data-models.github.io/dataModel.Transportation/context.jsonld",
{"cargoBikeNumber": "https://example.org/bicikelj/cargoBikeNumber"},
],
},
)
assert response.status_code == 201
CONTEXT_PATH = response.headers["Location"]
OWN_HEADERS = {
"Link": f'<http://localhost:1056{CONTEXT_PATH}>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"',
}
The answer is 201 Created, and its Location header gives the path of the stored @context, such as /ngsi-ld/v1/jsonldContexts/urn:ngsi-ld:JsonLdContext:0e02f141-3d97-442f-a312-08c0da7132f4. Name it in the Link header, with the broker's address in front, and add the attribute to Prešernov trg:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg/attrs
Content-Type: application/json
Link: <http://localhost:1056/ngsi-ld/v1/jsonldContexts/IDENTIFIER>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"cargoBikeNumber": 2
}
BICIKELJ='<http://localhost:1056/ngsi-ld/v1/jsonldContexts/IDENTIFIER>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"'
curl -i http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg/attrs \
-H 'Content-Type: application/json' \
-H "Link: $BICIKELJ" \
-d '{
"cargoBikeNumber": 2
}'
let bicikelj_client = client.with_context(IriRefBuf::new(context_url.as_str())?)?;
let cargo = EntityFragment::builder().property("cargoBikeNumber", 2).build()?;
bicikelj_client.entities().append_attributes(&presernov, &cargo).send().await?;
response = requests.post(f"{PRESERNOV}/attrs", headers=OWN_HEADERS, json={"cargoBikeNumber": 2})
assert response.status_code == 204
In the http and curl examples, replace IDENTIFIER with the one from Location. The answer is 204 No Content. Read the station back with the new @context, and cargoBikeNumber sits beside the Smart Data Models attributes. Read it with the published @context alone, and the new attribute comes back under its full IRI, because that @context doesn't know the short name:
{
"id": "urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"type": "BikeHireDockingStation",
"availableBikeNumber": 3,
"freeSlotNumber": 17,
"https://example.org/bicikelj/cargoBikeNumber": 2,
"name": "Prešernov trg",
"status": "working",
"totalSlotNumber": 20,
"location": {
"type": "Point",
"coordinates": [
14.50673,
46.05137
]
}
}
The same full IRI is what another system finds when it queries for cargo bikes with its own @context. JSON-LD @context has the rules.
Keeping a test copy apart
Everything so far went to the default tenant. To keep test data away from the real stations, add an NGSILD-Tenant header:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entities
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
NGSILD-Tenant: staging
{
"id": "urn:ngsi-ld:BikeHireDockingStation:test",
"type": "BikeHireDockingStation",
"name": "Test station",
"totalSlotNumber": 10
}
curl -i http://localhost:1056/ngsi-ld/v1/entities \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-H 'NGSILD-Tenant: staging' \
-d '{
"id": "urn:ngsi-ld:BikeHireDockingStation:test",
"type": "BikeHireDockingStation",
"name": "Test station",
"totalSlotNumber": 10
}'
let staging: Tenant = "staging".parse()?;
let test_station = Entity::builder()
.id(iri!("urn:ngsi-ld:BikeHireDockingStation:test"))
.entity_type("BikeHireDockingStation")
.property("name", "Test station")
.property("totalSlotNumber", 10)
.build()?;
client.entities().create(&test_station).tenant(&staging).send().await?;
response = requests.post(
f"{BASE}/entities",
headers={**HEADERS, "NGSILD-Tenant": "staging"},
json={
"id": "urn:ngsi-ld:BikeHireDockingStation:test",
"type": "BikeHireDockingStation",
"name": "Test station",
"totalSlotNumber": 10,
},
)
assert response.status_code == 201
The answer is 201 Created. The first write creates the tenant, with a database of its own. Counting stations in staging gives 1, and without the header you still count the 3 real ones. Tenants explains the rest.
When the broker says no
Andromeda checks every request against the specification and refuses what it doesn't allow. The error says which rule the request broke. Creating Trg OF a second time, for example:
- http
- curl
- rust
- python
POST /ngsi-ld/v1/entities
Content-Type: application/json
Link: <https://smart-data-models.github.io/dataModel.Transportation/context.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
{
"id": "urn:ngsi-ld:BikeHireDockingStation:trg-of",
"type": "BikeHireDockingStation",
"name": "Trg OF"
}
curl -i http://localhost:1056/ngsi-ld/v1/entities \
-H 'Content-Type: application/json' \
-H "Link: $CONTEXT" \
-d '{
"id": "urn:ngsi-ld:BikeHireDockingStation:trg-of",
"type": "BikeHireDockingStation",
"name": "Trg OF"
}'
let duplicate = Entity::builder()
.id(iri!("urn:ngsi-ld:BikeHireDockingStation:trg-of"))
.entity_type("BikeHireDockingStation")
.property("name", "Trg OF")
.build()?;
match client.entities().create(&duplicate).send().await {
Ok(_) => println!("created"),
Err(error) if error.error_type() == Some(&ErrorType::AlreadyExists) => {
if let Some(problem) = error.problem() {
println!("{:?}", problem.detail);
}
}
Err(error) => return Err(error.into()),
}
response = requests.post(
f"{BASE}/entities",
headers=HEADERS,
json={
"id": "urn:ngsi-ld:BikeHireDockingStation:trg-of",
"type": "BikeHireDockingStation",
"name": "Trg OF",
},
)
assert response.status_code == 409
print(response.json())
The answer is 409 Conflict, with a problem details body:
{
"type": "https://uri.etsi.org/ngsi-ld/errors/AlreadyExists",
"title": "Already exists",
"status": 409,
"detail": "entity urn:ngsi-ld:BikeHireDockingStation:trg-of already exists",
"instance": "/ngsi-ld/v1/entities"
}
A malformed request gets a 400 the same way. q=availableBikeNumber>>0, for example, is refused with the detail invalid query parameter q: q expression: expected value, found >. Common refusals lists the ones people meet first.
Cleaning up
Delete the subscription, the stations, the test station and the stored @context. Deleting an entity keeps its history, which you delete on its own:
- http
- curl
- rust
- python
DELETE /ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:empty-stations
POST /ngsi-ld/v1/entityOperations/delete
Content-Type: application/json
[
"urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"urn:ngsi-ld:BikeHireDockingStation:trg-of"
]
DELETE /ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:presernov-trg
DELETE /ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:kongresni-trg
DELETE /ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:trg-of
DELETE /ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:test
NGSILD-Tenant: staging
DELETE /ngsi-ld/v1/jsonldContexts/IDENTIFIER
curl -i -X DELETE http://localhost:1056/ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:empty-stations
curl -i http://localhost:1056/ngsi-ld/v1/entityOperations/delete \
-H 'Content-Type: application/json' \
-d '[
"urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"urn:ngsi-ld:BikeHireDockingStation:trg-of"
]'
for station in presernov-trg kongresni-trg trg-of; do
curl -i -X DELETE "http://localhost:1056/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:BikeHireDockingStation:$station"
done
curl -i -X DELETE http://localhost:1056/ngsi-ld/v1/entities/urn:ngsi-ld:BikeHireDockingStation:test \
-H 'NGSILD-Tenant: staging'
curl -i -X DELETE http://localhost:1056/ngsi-ld/v1/jsonldContexts/IDENTIFIER
client.subscriptions().delete(&empty_stations).send().await?;
let trg_of = EntityId::from(iri!("urn:ngsi-ld:BikeHireDockingStation:trg-of"));
let all = [presernov, kongresni, trg_of];
client.entities().batch_delete(&all).send().await?;
for station in &all {
client.temporal().delete(station).send().await?;
}
let test = EntityId::from(iri!("urn:ngsi-ld:BikeHireDockingStation:test"));
client.entities().delete(&test).tenant(&staging).send().await?;
client.contexts().delete(&context_id).send().await?;
STATIONS = [
"urn:ngsi-ld:BikeHireDockingStation:presernov-trg",
"urn:ngsi-ld:BikeHireDockingStation:kongresni-trg",
"urn:ngsi-ld:BikeHireDockingStation:trg-of",
]
response = requests.delete(f"{BASE}/subscriptions/urn:ngsi-ld:Subscription:empty-stations")
assert response.status_code == 204
response = requests.post(f"{BASE}/entityOperations/delete", json=STATIONS)
assert response.status_code == 204
for station in STATIONS:
response = requests.delete(f"{BASE}/temporal/entities/{station}")
assert response.status_code == 204
response = requests.delete(
f"{BASE}/entities/urn:ngsi-ld:BikeHireDockingStation:test",
headers={"NGSILD-Tenant": "staging"},
)
assert response.status_code == 204
response = requests.delete(f"http://localhost:1056{CONTEXT_PATH}")
assert response.status_code == 204
Each answers 204 No Content.
If it goes wrong
Most first refusals are a 400 with a detail that names the rule, or a bare 415 for a missing Content-Type. Troubleshooting lists the common ones.