Send positions, events and attachments from your devices or software into Gundi, and let it deliver them to EarthRanger, SMART, Movebank, wpsWatch and other supported platforms.
Quick Start
- 1Get an API keyCreate a Route in the Gundi Portal (how-to) and copy its key, or ask our Support Team for one.
-
2Pick an endpointUse
/observations/for tracked positions and/events/for reports, alerts and incidents. -
3Send your first requestRun the snippet below with your key. A
200with anobject_idmeans Gundi has it and is delivering it.
curl -X POST 'https://sensors.api.gundiservice.org/v2/observations/' \
--header 'apikey: {{API_KEY}}' \
--header 'Content-Type: application/json' \
--data '{
"source": "my-first-device",
"recorded_at": "2023-10-04T00:44:32Z",
"location": {"lat": -1.2921, "lon": 36.8219}
}'
Always end paths with a trailing slash (/observations/, not /observations). Without it the API answers with a redirect, and many HTTP clients follow it as a GET, which then fails.
Endpoints
/observations/Report the position of a tracked device, animal, vehicle or person.
POST/events/Create a report, alert or incident, optionally with a location and details.
PATCH/events/{object_id}/Change fields on an event you created earlier.
POST/events/{object_id}/attachments/Attach files, such as camera-trap images, to an event.
Basics
Authentication
All endpoints require an API key sent in the apikey header:
apikey: {{API_KEY}}
This key can be obtained from your Route created in the Gundi Portal. Check our documentation to create a new Route, or request the API Key from our Support Team. Requests that do not include a key, or include an invalid or expired key, are rejected (see Errors).
Please keep your API key secure and do not share it publicly, as it provides access to sensitive data and operations.
Response Format
All responses are JSON. A successful create returns the object_id Gundi assigned to the item and the time it was received:
200 OK
{
"object_id": "{{OBJECT_ID}}",
"created_at": "{{CREATED_AT}}"
}
Single and Bulk Requests
Every POST endpoint accepts either a single JSON object or a JSON list of objects. Sending a list creates all the items in one request, and the response is a list with one result per item, in the same order. Batching is recommended when you have many items to send, for example a backlog of positions from a device that was offline.
[
{ ... first item ... },
{ ... second item ... }
]
The response mirrors the request: one entry per item, in the same order.
200 OK
[
{"object_id": "{{OBJECT_ID_1}}", "created_at": "{{CREATED_AT}}"},
{"object_id": "{{OBJECT_ID_2}}", "created_at": "{{CREATED_AT}}"}
]
Timestamps and Coordinates
Timestamps must include a timezone; ISO 8601 is recommended (2023-07-27T09:34:00Z or 2023-07-27T09:34:00-03:00). Locations are decimal degrees in WGS-84, with lat between -90 and 90 and lon between -180 and 180.
Rate Limits
Gundi can process high volumes of data without enforcing a rate limit. However, destination systems (such as EarthRanger) may have difficulty handling data when it is sent at a fixed rate of around one location per second per device. In such cases, Gundi automatically retries, but we recommend reaching out to the Gundi team if you expect to send data at this frequency.
Versioning
This documentation is for version v2 of the API.
Events
/events/
Events can be used for reports, alerts, incidents, or any event that requires awareness or action. Send one event as a JSON object, or several at once as a JSON list.
Required Headers
| Header | Value |
|---|---|
apikey |
{{API_KEY}} — your API key |
Content-Type |
application/json |
Request Body Fields
| Field | Description |
|---|---|
event_type
string required
|
The EarthRanger Event Type or SMART category this report belongs to. Destinations reject events whose type they do not know. |
recorded_at
datetime required
|
When the event happened, with a timezone (e.g. 2023-07-27T09:34Z). |
location
object required
|
Where it happened: {"lat": …, "lon": …} in decimal degrees (WGS-84). |
title
string optional
|
A human-friendly title. Appears in EarthRanger's event feed and map view. If omitted, the destination's default title for the event type is used. |
source
string optional
|
Identifies the device or sensor that produced the event. |
event_details
object optional
|
Properties matching the schema of the event type (EarthRanger) or category (SMART Connect). Any valid JSON. |
Request Body
{
"event_type": "{{EVENT_TYPE}}",
"title": "{{EVENT_TITLE}}",
"recorded_at": "{{TIMESTAMP}}",
"location": {
"lat": {{LATITUDE}},
"lon": {{LONGITUDE}}
},
"event_details": {
...
}
}
Example
{
"event_type": "accident_rep",
"title": "Accident Report",
"recorded_at": "2023-10-03T09:35Z",
"location": {"lat": 20.117625, "lon": -103.113061},
"event_details": {"area": "1", "people_affected": "1", "tags": ["fall", "injury"]}
}
Response
Note down the object_id: you need it to update the event or attach files to it later.
200 OK
{
"object_id": "{{OBJECT_ID}}",
"created_at": "{{CREATED_AT}}"
}
Code Examples
cURL
curl -X POST 'https://sensors.api.gundiservice.org/v2/events/' \
--header 'apikey: {{API_KEY}}' \
--header 'Content-Type: application/json' \
--data '{
"event_type": "accident_rep",
"title": "Accident Report",
"recorded_at": "2023-10-03T09:35Z",
"location": {"lat": 20.117625, "lon": -103.113061},
"event_details": {"area": "1", "people_affected": "1", "tags": ["fall", "injury"]}
}'
Python
import requests
API_KEY = "{{API_KEY}}"
BASE_URL = "https://sensors.api.gundiservice.org/v2"
event = {
"event_type": "accident_rep",
"title": "Accident Report",
"recorded_at": "2023-10-03T09:35Z",
"location": {"lat": 20.117625, "lon": -103.113061},
"event_details": {"area": "1", "people_affected": "1", "tags": ["fall", "injury"]},
}
response = requests.post(
f"{BASE_URL}/events/",
json=event, # or a list of events
headers={"apikey": API_KEY},
timeout=30,
)
response.raise_for_status()
object_id = response.json()["object_id"] # keep this for updates and attachments
C#
using System.Net.Http;
using System.Net.Http.Json;
var apiKey = "{{API_KEY}}";
var baseUrl = "https://sensors.api.gundiservice.org/v2";
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("apikey", apiKey);
var evt = new
{
event_type = "accident_rep",
title = "Accident Report",
recorded_at = "2023-10-03T09:35Z",
location = new { lat = 20.117625, lon = -103.113061 },
event_details = new { area = "1", people_affected = "1", tags = new[] { "fall", "injury" } },
};
// Pass an array instead of a single object to send several at once.
var response = await client.PostAsJsonAsync($"{baseUrl}/events/", evt);
response.EnsureSuccessStatusCode();
Console.WriteLine(await response.Content.ReadAsStringAsync()); // {"object_id": ..., "created_at": ...}
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
String apiKey = "{{API_KEY}}";
String baseUrl = "https://sensors.api.gundiservice.org/v2";
// Build the JSON with your preferred library (Jackson, Gson, ...).
// Wrap it in [ ] to send several events at once.
String body = """
{
"event_type": "accident_rep",
"title": "Accident Report",
"recorded_at": "2023-10-03T09:35Z",
"location": {"lat": 20.117625, "lon": -103.113061},
"event_details": {"area": "1", "people_affected": "1", "tags": ["fall", "injury"]}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/events/"))
.header("apikey", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body()); // {"object_id": ..., "created_at": ...}
Update Events
/events/{object_id}/
To update an event previously sent to Gundi, use the PATCH method with the object_id from the create response and include only the properties you want to change.
Required Headers
| Header | Value |
|---|---|
apikey |
{{API_KEY}} — your API key |
Content-Type |
application/json |
Request Body Fields
Any of the fields accepted when creating an event, plus:
| Field | Description |
|---|---|
status
string optional
|
The new state of the event, for example resolved, active or new. |
Example
{
"status": "resolved",
"location": {"lat": 13.527, "lon": 13.154},
"event_details": {"number_people_involved": "3"}
}
Response
200 OK
{
"object_id": "{{OBJECT_ID}}",
"created_at": "{{CREATED_AT}}",
"updated_at": "{{UPDATED_AT}}"
}
Code Examples
cURL
curl -X PATCH 'https://sensors.api.gundiservice.org/v2/events/{{OBJECT_ID}}/' \
--header 'apikey: {{API_KEY}}' \
--header 'Content-Type: application/json' \
--data '{
"status": "resolved",
"location": {"lat": 13.527, "lon": 13.154},
"event_details": {"number_people_involved": "3"}
}'
Python
import requests
API_KEY = "{{API_KEY}}"
BASE_URL = "https://sensors.api.gundiservice.org/v2"
OBJECT_ID = "{{OBJECT_ID}}" # returned when the event was created
changes = {
"status": "resolved",
"location": {"lat": 13.527, "lon": 13.154},
"event_details": {"number_people_involved": "3"},
}
response = requests.patch(
f"{BASE_URL}/events/{OBJECT_ID}/",
json=changes,
headers={"apikey": API_KEY},
timeout=30,
)
response.raise_for_status()
C#
using System.Net.Http;
using System.Net.Http.Json;
var apiKey = "{{API_KEY}}";
var baseUrl = "https://sensors.api.gundiservice.org/v2";
var objectId = "{{OBJECT_ID}}"; // returned when the event was created
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("apikey", apiKey);
var changes = new
{
status = "resolved",
location = new { lat = 13.527, lon = 13.154 },
event_details = new { number_people_involved = "3" },
};
var response = await client.PatchAsJsonAsync($"{baseUrl}/events/{objectId}/", changes);
response.EnsureSuccessStatusCode();
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
String apiKey = "{{API_KEY}}";
String baseUrl = "https://sensors.api.gundiservice.org/v2";
String objectId = "{{OBJECT_ID}}"; // returned when the event was created
String body = """
{
"status": "resolved",
"location": {"lat": 13.527, "lon": 13.154},
"event_details": {"number_people_involved": "3"}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/events/" + objectId + "/"))
.header("apikey", apiKey)
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
Attachments
/events/{object_id}/attachments/
Camera trap images and other files can be attached to an event you created earlier. Replace {{OBJECT_ID}} in the URL with the value returned when the event was created.
Required Headers
| Header | Value |
|---|---|
apikey |
{{API_KEY}} — your API key |
Content-Type |
multipart/form-data (set automatically by most HTTP libraries when you attach a file) |
Request Body
One or more files sent as multipart/form-data. Each file goes in its own form field.
| Field | Description |
|---|---|
file1
file required
|
The file to attach. Add file2, file3, … to send several in one request. |
Response
One result per uploaded file.
200 OK
{
"object_id": "{{OBJECT_ID}}",
"created_at": "{{CREATED_AT}}"
}
Code Examples
cURL
curl -X POST 'https://sensors.api.gundiservice.org/v2/events/{{OBJECT_ID}}/attachments/' \
--header 'apikey: {{API_KEY}}' \
--form 'file1=@/path/to/photo.jpg'
Python
import requests
API_KEY = "{{API_KEY}}"
BASE_URL = "https://sensors.api.gundiservice.org/v2"
OBJECT_ID = "{{OBJECT_ID}}" # returned when the event was created
with open("photo.jpg", "rb") as f:
response = requests.post(
f"{BASE_URL}/events/{OBJECT_ID}/attachments/",
files={"file1": ("photo.jpg", f, "image/jpeg")},
headers={"apikey": API_KEY},
timeout=60,
)
response.raise_for_status()
C#
using System.Net.Http;
using System.Net.Http.Headers;
var apiKey = "{{API_KEY}}";
var baseUrl = "https://sensors.api.gundiservice.org/v2";
var objectId = "{{OBJECT_ID}}"; // returned when the event was created
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("apikey", apiKey);
using var form = new MultipartFormDataContent();
var file = new StreamContent(File.OpenRead("photo.jpg"));
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
form.Add(file, "file1", "photo.jpg");
var response = await client.PostAsync($"{baseUrl}/events/{objectId}/attachments/", form);
response.EnsureSuccessStatusCode();
Java
// Uses Apache HttpClient 5 (org.apache.httpcomponents.client5:httpclient5)
// because java.net.http has no built-in multipart support.
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ContentType;
import java.io.File;
String apiKey = "{{API_KEY}}";
String baseUrl = "https://sensors.api.gundiservice.org/v2";
String objectId = "{{OBJECT_ID}}"; // returned when the event was created
HttpPost post = new HttpPost(baseUrl + "/events/" + objectId + "/attachments/");
post.setHeader("apikey", apiKey);
post.setEntity(MultipartEntityBuilder.create()
.addBinaryBody("file1", new File("photo.jpg"), ContentType.IMAGE_JPEG, "photo.jpg")
.build());
try (var client = HttpClients.createDefault()) {
client.execute(post, response -> {
System.out.println(response.getCode());
return null;
});
}
Observations
/observations/
Observations can be used to track wildlife, rangers, and assets. Send one observation as a JSON object, or several at once as a JSON list.
Required Headers
| Header | Value |
|---|---|
apikey |
{{API_KEY}} — your API key |
Content-Type |
application/json |
Request Body Fields
| Field | Description |
|---|---|
source
string required
|
A unique identifier for the device reporting its position, such as a serial number or IMEI. |
recorded_at
datetime required
|
When the position was recorded, with a timezone (e.g. 2022-01-10T16:43:32Z). |
location
object required
|
The track point: {"lat": …, "lon": …} in decimal degrees (WGS-84). |
source_name
string optional
|
A human-friendly name for the device. If omitted, the source identifier is used. |
subject_type
string optional
|
What is being tracked (e.g. ranger, elephant, helicopter). In EarthRanger this becomes the subject's sub-type. |
additional
object optional
|
Custom key-value pairs specific to the device, passed through to the destination. Any valid JSON. |
Request Body
{
"source": "{{SOURCE_ID}}",
"subject_type": "{{SUBJECT_TYPE}}",
"source_name": "{{SOURCE_NAME}}",
"recorded_at": "{{TIMESTAMP}}",
"location": {
"lat": {{LATITUDE}},
"lon": {{LONGITUDE}}
},
"additional": {
}
}
Example
{
"source": "ST123456789",
"subject_type": "cow",
"source_name": "Buttercup",
"recorded_at": "2023-10-04T00:44:32Z",
"location": {"lat": -51.769228, "lon": -72.004443},
"additional": {"speed_kmph": 3}
}
Response
Observations cannot be updated later, so the object_id is mainly useful when contacting support about a specific data point.
200 OK
{
"object_id": "{{OBJECT_ID}}",
"created_at": "{{CREATED_AT}}"
}
Code Examples
cURL
curl -X POST 'https://sensors.api.gundiservice.org/v2/observations/' \
--header 'apikey: {{API_KEY}}' \
--header 'Content-Type: application/json' \
--data '{
"source": "ST123456789",
"subject_type": "cow",
"source_name": "Buttercup",
"recorded_at": "2023-10-04T00:44:32Z",
"location": {"lat": -51.769228, "lon": -72.004443},
"additional": {"speed_kmph": 3}
}'
Python
import requests
API_KEY = "{{API_KEY}}"
BASE_URL = "https://sensors.api.gundiservice.org/v2"
observation = {
"source": "ST123456789",
"subject_type": "cow",
"source_name": "Buttercup",
"recorded_at": "2023-10-04T00:44:32Z",
"location": {"lat": -51.769228, "lon": -72.004443},
"additional": {"speed_kmph": 3},
}
response = requests.post(
f"{BASE_URL}/observations/",
json=observation, # or a list of observations
headers={"apikey": API_KEY},
timeout=30,
)
response.raise_for_status()
print(response.json()) # {"object_id": "...", "created_at": "..."}
C#
using System.Net.Http;
using System.Net.Http.Json;
var apiKey = "{{API_KEY}}";
var baseUrl = "https://sensors.api.gundiservice.org/v2";
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("apikey", apiKey);
var observation = new
{
source = "ST123456789",
subject_type = "cow",
source_name = "Buttercup",
recorded_at = "2023-10-04T00:44:32Z",
location = new { lat = -51.769228, lon = -72.004443 },
additional = new { speed_kmph = 3 },
};
// Pass an array instead of a single object to send several at once.
var response = await client.PostAsJsonAsync($"{baseUrl}/observations/", observation);
response.EnsureSuccessStatusCode();
Console.WriteLine(await response.Content.ReadAsStringAsync());
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
String apiKey = "{{API_KEY}}";
String baseUrl = "https://sensors.api.gundiservice.org/v2";
// Build the JSON with your preferred library (Jackson, Gson, ...).
// Wrap it in [ ] to send several observations at once.
String body = """
{
"source": "ST123456789",
"subject_type": "cow",
"source_name": "Buttercup",
"recorded_at": "2023-10-04T00:44:32Z",
"location": {"lat": -51.769228, "lon": -72.004443},
"additional": {"speed_kmph": 3}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/observations/"))
.header("apikey", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Errors
Gundi validates every request before accepting it. Errors come back as JSON, keyed by the field that failed, so you can see exactly what to fix.
| Status | Meaning | What to do |
|---|---|---|
| 200 OK | Accepted. The body holds the object_id (or a list of them for bulk requests). |
Nothing. Gundi delivers the data to your destinations from here; check the Route's activity log in the portal if it does not arrive. |
| 301 Moved | The path is missing its trailing slash. | Use /observations/, not /observations. Clients that follow the redirect usually turn the request into a GET, which then fails with 405. |
| 400 Bad Request | Validation failed. The body maps each failing field to its messages; for bulk requests it is a list with one entry per item ({} for the items that were fine). |
Fix the fields named in the response and resend. Nothing from the rejected request was stored. |
| 400 Bad Request (API key) | The apikey header is missing or not recognised. The body is {"non_field_errors": ["“anonymous” is not a valid UUID."]}. |
Check that the header is named exactly apikey and that the key matches the Route in the portal. |
| 404 Not Found | A PATCH or attachment upload referred to an object_id Gundi does not know. |
Use the object_id returned when the event was created; it is not the ID from the destination system. |
| 5xx | A temporary problem on Gundi's side. | Retry with exponential backoff. Resending the same item shortly after is safe: Gundi discards exact duplicates received within a short window. |
Validation Error Example
400 Bad Request
{
"recorded_at": ["This field is required."],
"location": ["'location' requires valid 'lat' and 'lon' coordinates."]
}
Limitations
-
A
200means Gundi received the data, not that it reached the destination. Delivery to EarthRanger, SMART or other systems happens afterwards, and problems at that stage (for example an unknown event type) are not reported in the API response. Today you can check the outcome in the Route's activity log in the portal. Delivery feedback through the API is coming soon. - Observations cannot be changed or deleted once sent. If a position was wrong, send a corrected one; the destination keeps both.
- Attachments can only be added to events, not to observations, and only after the event has been created.
Questions about event types, subject types or delivery to a specific destination? Our team can help.
