Skip to main content

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.

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"

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:

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
]
}
}
]

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​

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"

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?

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"
[
{
"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:

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"

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:

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"

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:

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"
}
}

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:

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"
{
"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:

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"

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:

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"
}
}
}

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:

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"
}
}

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:

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"
}
]
}

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:

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
}

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:

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
}

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:

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"
}

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:

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

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.