API Developer Guide

Gundi · Developer Docs
Gundi API Reference

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.

Base URLhttps://sensors.api.gundiservice.org/v2/
Version v2Format JSONAuth apikey header

Quick Start

  1. 1Get an API keyCreate a Route in the Gundi Portal (how-to) and copy its key, or ask our Support Team for one.
  2. 2Pick an endpointUse /observations/ for tracked positions and /events/ for reports, alerts and incidents.
  3. 3Send your first requestRun the snippet below with your key. A 200 with an object_id means 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

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

POST/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

PATCH/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

POST/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

POST/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 200 means 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.
Need a hand?

Questions about event types, subject types or delivery to a specific destination? Our team can help.

Contact Gundi Support

Last update: Sep 7, 2026