Quick Start
- Get a tokenAsk your site admin for a long-lived token (how-to), or sign in for one with OAuth2 (see Authentication).
-
Make one callList the subjects your token can see. Replace
sandboxin the samples with your site's name. -
Read the resultThe answer sits inside
data. Every sample on this page reads it from there.
cURL
curl https://sandbox.pamdas.org/api/v1.0/subjects/ \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Accept: application/json"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/subjects/",
headers={"Authorization": "Bearer {{TOKEN}}", "Accept": "application/json"},
timeout=30,
)
response.raise_for_status()
for subject in response.json()["data"]:
print(subject["id"], subject["name"])er-client
# pip install earthranger-client
from erclient import ERClient
client = ERClient(service_root="https://sandbox.pamdas.org", token="{{TOKEN}}")
for subject in client.get_subjects(): # er-client unwraps "data" for you
print(subject["id"], subject["name"])200 OK
{
"data": [
{
"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"name": "Kibo",
"subject_subtype": "elephant",
"is_active": true
}
],
"status": {"code": 200, "message": "OK"}
}
End every path with a trailing slash (/subjects/, not /subjects), as in the samples on this page.
Endpoints
/activity/events/List and filter events.POST /activity/events/Create one event, or several at once.PATCH /activity/event/{id}/Change an event.GET /api/v2.0/activity/eventtypes/List event types and their forms.POST /api/v2.0/activity/eventtypes/Create an event type.POST /sensors/generic/{provider_key}/statusSend device positions; sources and subjects are created for you.GET /subjects/List subjects and their last positions.GET /subject/{id}/tracks/Get a subject's track.GET /observations/List single positions.GET /activity/patrols/List and filter patrols.
Paths are under /api/v1.0 unless they say otherwise.
Basics
Authentication
Send your token in the Authorization header of every request:
Authorization: Bearer {{TOKEN}}
There are two ways to get a token:
- Long-lived token (best for integrations). A site admin creates it in the EarthRanger admin portal and sets when it expires. See Create and Manage Authentication Tokens for Secure Integrations.
-
OAuth2 sign-in. Send a username and password to
/oauth2/token/. The access token lasts 48 hours and the refresh token 62 days.
A token acts as the user it belongs to, so that user's permissions decide what the token can read and change.
cURL
curl -X POST https://sandbox.pamdas.org/oauth2/token/ \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "username={{USERNAME}}" \
-d "password={{PASSWORD}}" \
-d "client_id={{CLIENT_ID}}"Python
import requests
response = requests.post(
"https://sandbox.pamdas.org/oauth2/token/",
data={
"grant_type": "password",
"username": "{{USERNAME}}",
"password": "{{PASSWORD}}",
"client_id": "{{CLIENT_ID}}",
},
timeout=30,
)
response.raise_for_status()
token = response.json() # this endpoint has no "data" wrapper
access_token = token["access_token"]
refresh_token = token["refresh_token"]er-client
from erclient import ERClient
# er-client signs in and refreshes the token for you
client = ERClient(
service_root="https://sandbox.pamdas.org",
username="{{USERNAME}}",
password="{{PASSWORD}}",
client_id="{{CLIENT_ID}}",
)
me = client.get_me()200 OK
{
"access_token": "Gx7pQ2mZ9vT4kLw8sR1nY6bD3hF0aJcE",
"expires_in": 172800,
"token_type": "Bearer",
"refresh_token": "Vb5nM2qW8eR4tY7uI1oP3aS6dF9gH0jK"
}
To refresh, send grant_type=refresh_token, the refresh_token and your client_id to the same URL. To revoke a token, send token and client_id to /oauth2/revoke_token/. Ask your site admin which client_id to use, and whether it needs a client_secret.
Keep your token secret and never share it publicly. Treat it like a password.
Reference Docs and Python Client
| Resource | Where |
|---|---|
| Interactive docs (try calls in the browser) | https://sandbox.pamdas.org/api/v1.0/docs/interactive/ |
| ReDoc reference | https://sandbox.pamdas.org/api/v1.0/docs/redoc/ |
| OpenAPI schema (raw) | https://sandbox.pamdas.org/api/v1.0/api-schema/ |
| er-client, the Python client | github.com/PADAS/er-client (examples) |
Replace sandbox with your site's name. Each site's docs list only the features that site has turned on.
Response Format
Responses are JSON. The result sits inside data, next to a status block:
{
"data": { ... the result ... },
"status": {"code": 200, "message": "OK"}
}
Every result is inside data. Read the result from response.json()["data"]. Three kinds of response have no data wrapper: the /oauth2/token/ answer, the event type /schema/ route, and most errors (see Errors). A DELETE answers HTTP 200 with "status": {"code": 204} in the body.
Paging
The events, patrols and observations lists come in pages. Send use_cursor=true and follow the next URL until it is null. page_size sets how many items come back per page (default 25, maximum 4000).
{
"data": {
"next": "https://sandbox.pamdas.org/api/v1.0/activity/events/?cursor=cD0yMDI2&use_cursor=true",
"previous": null,
"results": [ ... ]
},
"status": {"code": 200, "message": "OK"}
}
Deprecated. Page-number paging (page=2) on the events and patrols lists ends on 23 May 2027. Those responses already carry Deprecation and Sunset headers. Switch to use_cursor=true. er-client's get_events and get_patrols still use page numbers.
The subjects list is not paged unless you send page_size. The event types list is never paged.
The filter Parameter
The events and patrols lists take extra filters as a JSON object in the filter query parameter. That is where date ranges go. URL-encode it (curl's --data-urlencode does this for you):
filter={"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"}}
Filters by event type or patrol type take the type's id (a UUID), not its value such as wildlife_sighting_rep. Get the ids from the event types list or the patrol types list.
Dates and Coordinates
Send dates in ISO 8601 with a timezone: 2026-11-01T08:30:00Z or 2026-11-01T08:30:00+03:00. A date without a timezone is read as UTC.
Events, subjects and observations use {"latitude": …, "longitude": …}. The sensors endpoint uses {"lat": …, "lon": …}. GeoJSON (tracks, last_position) lists coordinates as [longitude, latitude].
A bounding box (bbox) is west,south,east,north, so longitude first: bbox=34.7,-2.5,35.0,-2.3 covers longitudes 34.7 to 35.0 and latitudes -2.5 to -2.3.
Caching with ETags
A single event, the event types, subject groups and patrol types send an ETag header. Send it back as If-None-Match. If nothing changed, you get 304 Not Modified with no body.
Events
Events are the reports in EarthRanger: sightings, incidents, alerts. Access follows the event's category: a token only sees and changes events in categories its user has permission for.
List Events
/api/v1.0/activity/events/needs: read permission on the category
| Name | Default | Notes |
|---|---|---|
|
boolean optional
|
false |
true turns on cursor paging, newest first. Recommended. |
|
integer optional
|
25 | Maximum 4000. |
|
string, repeat optional
|
all |
new, active, resolved or review. Repeat for several: state=new&state=active. |
|
UUID, repeat optional
|
all | The event type's id, not its value. |
|
string, repeat optional
|
all | Category value, e.g. monitoring. |
|
UUID, repeat optional
|
Only these events. | |
|
datetime optional
|
Events changed at or after this time. Good for syncing. | |
|
string optional
|
west,south,east,north. |
|
|
JSON optional
|
See the filter keys below. | |
|
boolean optional
|
true: only collections; false: no collections. |
|
|
boolean optional
|
true leaves out events inside a collection. Don't combine with is_collection. |
|
|
string optional
|
-sort_at |
event_time, updated_at, created_at, serial_number or sort_at; a - prefix sorts newest first. sort_at is the last time anything on the event changed. With use_cursor=true, leave it out. |
|
boolean optional
|
true |
Include event_details. |
|
boolean optional
|
true |
Include the change history. |
|
boolean optional
|
true |
Include attached files. |
|
boolean optional
|
false |
Include notes. |
|
boolean optional
|
false |
Include linked and contained events. |
| Key | Value | Matches |
|---|---|---|
date_range |
{"lower": …, "upper": …} |
Event time. Either end can be left out. |
text |
string | Words in the event or its notes, or the start of its serial number. |
duration |
ISO duration, e.g. P7D
|
Event time within that long before now. Ignored when date_range is set. |
state |
list | Event states. |
priority |
list of 0, 100, 200, 300 | Priorities. |
event_type |
list of UUIDs | Event type ids. |
event_category |
list of UUIDs | Event category ids. |
reported_by |
list of UUIDs | Reporter ids. |
create_date |
{"lower": …, "upper": …} |
When the event was created. |
update_date |
{"lower": …, "upper": …} |
When the event was last changed. |
cURL
curl -G "https://sandbox.pamdas.org/api/v1.0/activity/events/" \
-H "Authorization: Bearer {{TOKEN}}" \
--data-urlencode 'filter={"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"}}' \
--data-urlencode "state=active" \
--data-urlencode "state=new" \
--data-urlencode "use_cursor=true" \
--data-urlencode "page_size=100"Python
import json
import requests
BASE = "https://sandbox.pamdas.org/api/v1.0"
HEADERS = {"Authorization": "Bearer {{TOKEN}}"}
params = [
("filter", json.dumps({"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"}})),
("state", "active"),
("state", "new"),
("use_cursor", "true"),
("page_size", "100"),
]
events = []
url = f"{BASE}/activity/events/"
while url:
response = requests.get(url, headers=HEADERS, params=params, timeout=30)
response.raise_for_status()
page = response.json()["data"]
events.extend(page["results"])
url = page["next"]
params = None # the next URL already holds every parameter
print(len(events))er-client
import json
from erclient import ERClient
client = ERClient(service_root="https://sandbox.pamdas.org", token="{{TOKEN}}")
for event in client.get_events(
filter=json.dumps({"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"}}),
state=["active", "new"],
page_size=100,
):
print(event["serial_number"], event["title"])200 OK
{
"data": {
"next": null,
"previous": null,
"results": [
{
"id": "8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f",
"serial_number": 1042,
"event_type": "wildlife_sighting_rep",
"event_category": "monitoring",
"title": "Elephant herd near the river",
"time": "2026-11-01T08:30:00Z",
"location": {"latitude": -2.4, "longitude": 34.85},
"priority": 200,
"priority_label": "Amber",
"state": "active",
"event_details": {"species": "elephant", "herd_size": 12},
"related_subjects": [],
"updated_at": "2026-11-01T08:31:02.112000Z",
"created_at": "2026-11-01T08:31:02.098000Z"
}
]
},
"status": {"code": 200, "message": "OK"}
}
(Trimmed: real events carry more fields.)
Get One Event
/api/v1.0/activity/event/{id}/needs: read permission on the category
Returns the full event, including event_details, updates, notes, files and related events. Turn parts off with include_updates=false, include_notes=false, include_files=false or include_related_events=false.
cURL
curl "https://sandbox.pamdas.org/api/v1.0/activity/event/8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f/" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/activity/event/8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f/",
headers={"Authorization": "Bearer {{TOKEN}}"},
timeout=30,
)
response.raise_for_status()
event = response.json()["data"]
print(event["title"], event["state"])er-client
event = client.get_event(event_id="8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f")Create an Event
/api/v1.0/activity/events/needs: create permission on the category
Send one event as a JSON object, or several as a JSON list. With a list, all events are created together: if one fails, none is created and the reply lists the errors.
| Name | Default | Notes |
|---|---|---|
|
string required
|
The event type's value, e.g. wildlife_sighting_rep. |
|
|
datetime optional
|
now | When it happened. |
|
object optional
|
{"latitude": …, "longitude": …}. For polygon event types, send geometry (GeoJSON) instead. |
|
|
string optional
|
event type name | Shown in the event feed. |
|
integer optional
|
event type default |
0 gray, 100 green, 200 amber, 300 red. |
|
string optional
|
event type default |
new, active, resolved or review. |
|
object optional
|
{} |
The form's fields, keyed by the names in the event type's schema. |
|
object optional
|
{"content_type": "accounts.user", "id": "…"}, or observations.subject for a subject. |
|
|
list optional
|
Subjects linked to the event: [{"id": "…"}]. |
|
|
list optional
|
[{"text": "…"}]. |
|
|
list optional
|
Patrol segment ids to link the event to. |
Set end_time with an update after the event exists, not in the create request. On a create, priority: 0 means "use the event type's default priority".
cURL
curl -X POST "https://sandbox.pamdas.org/api/v1.0/activity/events/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{
"event_type": "wildlife_sighting_rep",
"title": "Elephant herd near the river",
"time": "2026-11-01T08:30:00Z",
"location": {"latitude": -2.4, "longitude": 34.85},
"priority": 200,
"state": "active",
"event_details": {"species": "elephant", "herd_size": 12},
"reported_by": {"content_type": "accounts.user", "id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b"},
"notes": [{"text": "Herd of about 12 heading north."}]
}'Python
import requests
BASE = "https://sandbox.pamdas.org/api/v1.0"
HEADERS = {"Authorization": "Bearer {{TOKEN}}"}
event = {
"event_type": "wildlife_sighting_rep",
"title": "Elephant herd near the river",
"time": "2026-11-01T08:30:00Z",
"location": {"latitude": -2.4, "longitude": 34.85},
"priority": 200,
"state": "active",
"event_details": {"species": "elephant", "herd_size": 12},
"reported_by": {"content_type": "accounts.user", "id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b"},
"notes": [{"text": "Herd of about 12 heading north."}],
}
response = requests.post(f"{BASE}/activity/events/", headers=HEADERS, json=event, timeout=30)
response.raise_for_status()
created = response.json()["data"]
print(created["id"], created["serial_number"])er-client
created = client.post_event(event) # also accepts a list
print(created["id"], created["serial_number"])201 Created
{
"data": {
"id": "8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f",
"serial_number": 1042,
"event_type": "wildlife_sighting_rep",
"event_category": "monitoring",
"title": "Elephant herd near the river",
"time": "2026-11-01T08:30:00Z",
"end_time": null,
"location": {"latitude": -2.4, "longitude": 34.85},
"priority": 200,
"priority_label": "Amber",
"state": "active",
"event_details": {"species": "elephant", "herd_size": 12},
"reported_by": {
"id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"content_type": "accounts.user",
"username": "jdoe",
"first_name": "Jane",
"last_name": "Doe"
},
"notes": [{"text": "Herd of about 12 heading north."}],
"related_subjects": [],
"url": "https://sandbox.pamdas.org/api/v1.0/activity/event/8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f",
"created_at": "2026-11-01T08:31:02.098000Z",
"updated_at": "2026-11-01T08:31:02.112000Z"
},
"status": {"code": 201, "message": "Created"}
}
(Trimmed.) To create several at once, send a list; data is then a list too:
[
{"event_type": "wildlife_sighting_rep", "title": "Elephant herd near the river", "time": "2026-11-01T08:30:00Z", "location": {"latitude": -2.4, "longitude": 34.85}},
{"event_type": "wildlife_sighting_rep", "title": "Lone bull at the crossing", "time": "2026-11-01T09:10:00Z", "location": {"latitude": -2.41, "longitude": 34.86}}
]
Some sites limit how fast events can be created (600 per minute per site, 10 requests at a time). A list of N events counts as N. Over the limit you get 429 with a Retry-After header: wait that many seconds, then resend.
Update an Event
/api/v1.0/activity/event/{id}/needs: update permission on the category
Send only the fields you want to change. What happens to each:
| Field | What happens |
|---|---|
title, state, priority, time, end_time, location, … |
Set to the new value. end_time can't be earlier than time. |
event_details |
Replaced as a whole. Send every field you want to keep. |
related_subjects |
The list replaces the linked subjects; [] removes them all. Leave the key out to keep them. |
notes |
A note with an id is edited; a note without one is added. Notes you leave out stay. |
cURL
curl -X PATCH "https://sandbox.pamdas.org/api/v1.0/activity/event/8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{
"state": "resolved",
"priority": 300,
"related_subjects": [{"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f"}]
}'Python
import requests
patch = {
"state": "resolved",
"priority": 300,
"related_subjects": [{"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f"}],
}
response = requests.patch(
"https://sandbox.pamdas.org/api/v1.0/activity/event/8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json=patch,
timeout=30,
)
response.raise_for_status()
updated = response.json()["data"]
print(updated["state"], updated["priority"])er-client
updated = client.patch_event("8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f", patch)200 OK
{
"data": {
"id": "8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f",
"serial_number": 1042,
"title": "Elephant herd near the river",
"priority": 300,
"priority_label": "Red",
"state": "resolved",
"related_subjects": [
{
"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"name": "Kibo",
"subject_subtype": "elephant",
"content_type": "observations.subject"
}
],
"updated_at": "2026-11-01T10:05:40.551000Z"
},
"status": {"code": 200, "message": "OK"}
}
(Trimmed.)
More Event Endpoints
| Path | Methods | What it does |
|---|---|---|
/activity/event/{id}/ |
DELETE | Deletes the event (needs delete permission on the category). |
/activity/events/bulk-delete/ |
POST | Deletes several: {"ids": [...]}. All or nothing. |
/activity/event/{id}/state/ |
GET, PATCH | Reads or sets only the state: {"state": "resolved"}. |
/activity/event/{id}/notes/ |
GET, POST | Lists or adds notes: {"text": "…"}. |
/activity/event/{id}/files/ |
GET, POST | Lists or uploads files (multipart field filecontent.file). |
/activity/event/{id}/relationships/ |
GET, POST | Links events: {"type": "contains", "to_event_id": "…"} or is_linked_to. |
All paths start with /api/v1.0.
Event Types
Event types define the forms behind events. These are the v2.0 routes: they work with v2 event types only, and look a type up by its value (wildlife_sighting_rep) or its id. Access follows the type's category.
Deprecated. The v1.0 event type routes (/api/v1.0/activity/events/eventtypes/) reached their sunset on 15 October 2026. Use the v2.0 routes below.
List Event Types
/api/v2.0/activity/eventtypes/needs: read permission on the category
| Name | Default | Notes |
|---|---|---|
|
string optional
|
all | One or more category values, comma-separated: monitoring,security. |
|
boolean optional
|
false |
Also return inactive types. |
|
boolean optional
|
false |
Add each type's schema (as a JSON string). |
|
datetime optional
|
Types changed at or after this time. | |
|
boolean optional
|
Only collection types, or none. | |
|
string optional
|
ordernum |
Any field, - for descending, e.g. display. |
cURL
curl "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/?category=monitoring&sort_by=display" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/",
headers={"Authorization": "Bearer {{TOKEN}}"},
params={"category": "monitoring", "sort_by": "display"},
timeout=30,
)
response.raise_for_status()
for event_type in response.json()["data"]: # a plain list
print(event_type["id"], event_type["value"], event_type["display"])er-client
event_types = client.get_event_types(version="v2.0") # the default is v1.0200 OK
{
"data": [
{
"id": "6c5e0d3a-2f4b-4c1e-9a8d-3b7f1e2a9c41",
"url": "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep",
"value": "wildlife_sighting_rep",
"display": "Wildlife sighting",
"category": "monitoring",
"geometry_type": "Point",
"default_priority": 100,
"default_state": "new",
"resolve_time": 72,
"auto_resolve": true,
"is_collection": false,
"is_active": true,
"has_events_assigned": true,
"icon_id": "generic_rep",
"ordernum": 12.0
}
],
"status": {"code": 200, "message": "OK"}
}
Get One Event Type and Its Schema
/api/v2.0/activity/eventtypes/{value_or_id}/needs: read permission on the category
Returns one type, with the same fields as the list. Add include_inactive=true to read an inactive one. To get the form as a JSON object, call the /schema/ route. Its answer has no data wrapper. Add pre_render=true to fill in choice lists, and s_format=enum to get them as enum instead of oneOf.
/api/v2.0/activity/eventtypes/{value_or_id}/schema/
cURL
curl "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/schema/?pre_render=true&s_format=enum" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/schema/",
headers={"Authorization": "Bearer {{TOKEN}}"},
params={"pre_render": "true", "s_format": "enum"},
timeout=30,
)
response.raise_for_status()
schema = response.json() # no "data" wrapper on this route
print(list(schema["json"]["properties"]))er-client
event_type = client.get_event_type("wildlife_sighting_rep", version="v2.0")Create an Event Type
/api/v2.0/activity/eventtypes/
| Name | Default | Notes |
|---|---|---|
|
string required
|
Letters, digits, - and _; saved in lowercase. Unique on the site. |
|
|
string required
|
Category value, e.g. monitoring. |
|
|
object required
|
The form: json (the fields) and ui (the layout). See the sample. |
|
|
string optional
|
"" |
Name shown in the apps. |
|
integer optional
|
0 |
0, 100, 200 or 300. |
|
string optional
|
new |
new, active, resolved or review. |
|
string optional
|
Point |
Point or Polygon. |
|
boolean optional
|
false |
Resolve events automatically after resolve_time. |
|
integer optional
|
null |
In hours. Set it only when auto_resolve is true. |
|
boolean optional
|
false |
Events of this type can group other events. |
|
boolean optional
|
true |
false hides the type. |
|
string optional
|
Icon id. |
In the schema, every field needs type, title and deprecated, and needs an entry in ui.fields. Choice fields take their options from a choice list ($ref to /api/v2.0/schemas/choices.json?field=…); an admin creates that list under Choices. The sample below passes the server's validation.
cURL
curl -X POST "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{
"value": "wildlife_sighting_rep",
"display": "Wildlife sighting",
"category": "monitoring",
"default_priority": 100,
"default_state": "new",
"geometry_type": "Point",
"auto_resolve": true,
"resolve_time": 72,
"schema": {
"json": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"unevaluatedProperties": false,
"properties": {
"species": {
"type": "string",
"title": "Species",
"description": "Species seen.",
"deprecated": false,
"anyOf": [
{"$ref": "/api/v2.0/schemas/choices.json?field=wildlife_sighting_rep_species"}
]
},
"herd_size": {
"type": "number",
"title": "Herd size",
"description": "How many animals were seen.",
"deprecated": false,
"minimum": 1
},
"notes": {
"type": "string",
"title": "Notes",
"description": "",
"deprecated": false
}
},
"required": ["species"]
},
"ui": {
"fields": {
"species": {"type": "CHOICE_LIST", "inputType": "DROPDOWN", "parent": "section-details"},
"herd_size": {"type": "NUMERIC", "placeholder": "12", "parent": "section-details"},
"notes": {"type": "TEXT", "inputType": "LONG_TEXT", "parent": "section-details"}
},
"headers": {},
"order": ["section-details"],
"sections": {
"section-details": {
"columns": 1,
"isActive": true,
"label": "Sighting details",
"leftColumn": [
{"name": "species", "type": "field"},
{"name": "herd_size", "type": "field"},
{"name": "notes", "type": "field"}
],
"rightColumn": []
}
}
}
}
}'Python
schema = {
"json": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"unevaluatedProperties": False,
"properties": {
"species": {
"type": "string",
"title": "Species",
"description": "Species seen.",
"deprecated": False,
"anyOf": [{"$ref": "/api/v2.0/schemas/choices.json?field=wildlife_sighting_rep_species"}],
},
"herd_size": {
"type": "number",
"title": "Herd size",
"description": "How many animals were seen.",
"deprecated": False,
"minimum": 1,
},
"notes": {"type": "string", "title": "Notes", "description": "", "deprecated": False},
},
"required": ["species"],
},
"ui": {
"fields": {
"species": {"type": "CHOICE_LIST", "inputType": "DROPDOWN", "parent": "section-details"},
"herd_size": {"type": "NUMERIC", "placeholder": "12", "parent": "section-details"},
"notes": {"type": "TEXT", "inputType": "LONG_TEXT", "parent": "section-details"},
},
"headers": {},
"order": ["section-details"],
"sections": {
"section-details": {
"columns": 1,
"isActive": True,
"label": "Sighting details",
"leftColumn": [
{"name": "species", "type": "field"},
{"name": "herd_size", "type": "field"},
{"name": "notes", "type": "field"},
],
"rightColumn": [],
}
},
},
}
import requests
payload = {
"value": "wildlife_sighting_rep",
"display": "Wildlife sighting",
"category": "monitoring",
"default_priority": 100,
"default_state": "new",
"geometry_type": "Point",
"auto_resolve": True,
"resolve_time": 72,
"schema": schema,
}
response = requests.post(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json=payload,
timeout=30,
)
response.raise_for_status()
print(response.json()["data"]["resource_url"])er-client
result = client.post_event_type(payload, version="v2.0") # the default is v1.0
print(result["resource_url"])201 Created
{
"data": {"resource_url": "/api/v2.0/activity/eventtypes/wildlife_sighting_rep"},
"status": {"code": 201, "message": "Created"}
}
The answer is a link to the new type, not the type itself. Read it with a GET to get its id.
Update an Event Type
/api/v2.0/activity/eventtypes/{value_or_id}/needs: update permission on the category
Send only the fields to change. Leave schema out to keep the form as it is. The answer is {"data": null}: read the type again to see the result.
cURL
curl -X PATCH "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{"default_priority": 200}'Python
import requests
response = requests.patch(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json={"default_priority": 200},
timeout=30,
)
response.raise_for_status() # data is null on successer-client
client.patch_event_type({"value": "wildlife_sighting_rep", "default_priority": 200}, version="v2.0")200 OK
{"data": null, "status": {"code": 200, "message": "OK"}}
Delete an Event Type
/api/v2.0/activity/eventtypes/{value_or_id}/needs: delete permission on the category
A type that events or alert rules use can't be deleted: the answer is 409 with the reason in data.detail. To retire it instead, PATCH {"is_active": false}.
cURL
curl -X DELETE "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.delete(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/",
headers={"Authorization": "Bearer {{TOKEN}}"},
timeout=30,
)
if response.status_code == 409:
print(response.json()["data"]["detail"])
else:
response.raise_for_status()200 OK
{"data": null, "status": {"code": 204, "message": "No Content"}}
409 Conflict
{
"data": {"detail": "Cannot delete Event Type 'Wildlife sighting' because it is associated with existing Events."},
"status": {"code": 409, "message": "Conflict"}
}
Reorder Event Types
/api/v2.0/activity/eventtypes/{value_or_id}/rank/needs: create permission on the category
Moves the type in the site's display order. after is required: the value or id of the type it should follow, or null to move it to the front. category (optional) moves it to another category at the same time.
cURL
curl -X POST "https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/rank/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{"after": null}'Python
import requests
response = requests.post(
"https://sandbox.pamdas.org/api/v2.0/activity/eventtypes/wildlife_sighting_rep/rank/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json={"after": None}, # move to the front
timeout=30,
)
response.raise_for_status()Send Tracking Data
Device makers: use Gundi. Building or selling tracking devices, or running a platform that collects device data? Connect it through Gundi instead of posting to one site: see the Gundi API guide.
Two endpoints send positions straight to one EarthRanger site:
| Endpoint | Use it when |
|---|---|
Sensors: POST /api/v1.0/sensors/generic/{provider_key}/status
|
You know the device's own id. EarthRanger creates the source, its provider and a subject the first time it sees a device. |
Observations: POST /api/v1.0/observations/
|
The source already exists in EarthRanger and you have its id. Nothing is created for you. |
Sensors Endpoint
/api/v1.0/sensors/generic/{provider_key}/status
provider_key names your data provider, e.g. my-collar-company. Send one position or a list. Subject details are used only the first time a device appears.
| Name | Default | Notes |
|---|---|---|
|
string required
|
The device's id. Unique per provider. | |
|
datetime required
|
When the position was taken, with a timezone. | |
|
object required
|
{"lat": …, "lon": …} (not latitude/longitude). |
|
|
string optional
|
the manufacturer_id
|
Name of the new subject. |
|
string optional
|
ranger |
Subtype value, e.g. elephant. |
|
list optional
|
site default group | Subject group names. Missing groups are created. |
|
UUID optional
|
Link the device to this existing subject instead of creating one. | |
|
string optional
|
tracking-device, trap, seismic, firms or gps-radio. |
|
|
object optional
|
{} |
Any extra values, such as speed or battery. |
cURL
curl -X POST "https://sandbox.pamdas.org/api/v1.0/sensors/generic/my-collar-company/status" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{"manufacturer_id": "collar-0457", "recorded_at": "2026-11-01T08:30:00Z", "location": {"lat": -2.4, "lon": 34.85}, "subject_name": "Kibo", "subject_subtype": "elephant", "subject_groups": ["Elephants"], "source_type": "tracking-device", "additional": {"speed_kmph": 1.2}}'Python
import requests
body = {
"manufacturer_id": "collar-0457",
"recorded_at": "2026-11-01T08:30:00Z",
"location": {"lat": -2.4, "lon": 34.85},
"subject_name": "Kibo",
"subject_subtype": "elephant",
"subject_groups": ["Elephants"],
"source_type": "tracking-device",
"additional": {"speed_kmph": 1.2},
}
response = requests.post(
"https://sandbox.pamdas.org/api/v1.0/sensors/generic/my-collar-company/status",
headers={"Authorization": "Bearer {{TOKEN}}"},
json=body,
timeout=30,
)
response.raise_for_status()
print(response.status_code) # 201 = saved, 200 = all were already storeder-client
from datetime import datetime, timezone
from erclient import ERClient
client = ERClient(service_root="https://sandbox.pamdas.org", token="{{TOKEN}}", provider_key="my-collar-company")
client.post_sensor_observation({
"manufacturer_id": "collar-0457",
"recorded_at": datetime(2026, 11, 1, 8, 30, tzinfo=timezone.utc),
"location": {"lat": -2.4, "lon": 34.85},
"subject_name": "Kibo",
"subject_subtype": "elephant",
})201 Created
{"data": {}, "status": {"code": 201, "message": "Created"}}
201 means at least one new position was saved. 200 means every position was already stored; duplicates are skipped.
Observations Endpoint
/api/v1.0/observations/needs: add observation permission
| Name | Default | Notes |
|---|---|---|
|
UUID required
|
The id of an existing source. | |
|
datetime required
|
With a timezone. | |
|
object required
|
{"latitude": …, "longitude": …}. |
|
|
object optional
|
Any extra values. |
cURL
curl -X POST "https://sandbox.pamdas.org/api/v1.0/observations/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{"source": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "recorded_at": "2026-11-01T08:30:00Z", "location": {"latitude": -2.4, "longitude": 34.85}, "additional": {"speed_kmph": 1.2}}'Python
import requests
body = {
"source": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"recorded_at": "2026-11-01T08:30:00Z",
"location": {"latitude": -2.4, "longitude": 34.85},
"additional": {"speed_kmph": 1.2},
}
response = requests.post(
"https://sandbox.pamdas.org/api/v1.0/observations/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json=body,
timeout=30,
)
response.raise_for_status()
print(response.json()["data"]["id"])er-client
client.post_observation({
"source": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"recorded_at": datetime(2026, 11, 1, 8, 30, tzinfo=timezone.utc),
"location": {"latitude": -2.4, "longitude": 34.85},
})201 Created
{
"data": {
"id": "e4b7c2a1-3d5f-4e6a-8b9c-0d1e2f3a4b5c",
"location": {"latitude": -2.4, "longitude": 34.85},
"created_at": "2026-11-01T08:31:02.114233Z",
"recorded_at": "2026-11-01T08:30:00Z",
"source": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"exclusion_flags": 0
},
"status": {"code": 201, "message": "Created"}
}
A position that is already stored for that source and time returns 409.
Subjects, Groups and Tracks
Subjects are what you track: animals, rangers, vehicles. A token sees the subjects in the subject groups its user has permission for.
List Subjects
/api/v1.0/subjects/needs: view subject permission
Returns active subjects as a plain list. Send page_size to get pages instead.
| Name | Default | Notes |
|---|---|---|
|
UUID optional
|
One group and the groups inside it. Several ids, comma-separated: subjects in any of them. | |
|
UUIDs optional
|
Only these subjects, comma-separated. | |
|
string optional
|
Name contains this text (any case). | |
|
string optional
|
Subtype values, comma-separated: elephant,rhino. |
|
|
string optional
|
Type value, e.g. wildlife. |
|
|
string optional
|
Exact match on a key in additional, e.g. additional.sex=female. |
|
|
boolean optional
|
false |
Also return inactive subjects. |
|
datetime optional
|
Subjects changed since. Don't combine with position_updated_since. |
|
|
datetime optional
|
Subjects whose position changed since. | |
|
string optional
|
Subjects with track points in this box. | |
|
boolean optional
|
false |
Add each subject's recent track. Narrow it with tracks_since, tracks_until, tracks_limit. |
|
boolean optional
|
true |
false leaves out last_position. |
|
integer optional
|
not paged | Turns on paging. |
cURL
curl "https://sandbox.pamdas.org/api/v1.0/subjects/?subject_group=5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d&subject_subtypes=elephant" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/subjects/",
headers={"Authorization": "Bearer {{TOKEN}}"},
params={"subject_group": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "subject_subtypes": "elephant"},
timeout=30,
)
response.raise_for_status()
for subject in response.json()["data"]:
position = subject.get("last_position")
if position:
lon, lat = position["geometry"]["coordinates"]
print(subject["name"], lat, lon)er-client
subjects = client.get_subjects(subject_group="5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d")200 OK
{
"data": [
{
"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"name": "Kibo",
"subject_type": "wildlife",
"subject_subtype": "elephant",
"additional": {"sex": "male"},
"is_active": true,
"last_position_date": "2026-11-01T08:30:00+00:00",
"last_position": {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [34.85, -2.4]},
"properties": {
"title": "Kibo",
"coordinateProperties": {"time": "2026-11-01T08:30:00+00:00"}
}
},
"url": "https://sandbox.pamdas.org/api/v1.0/subject/2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f"
}
],
"status": {"code": 200, "message": "OK"}
}
(Trimmed.) last_position is GeoJSON, so coordinates are [longitude, latitude]. One subject: GET /api/v1.0/subject/{id}/.
Create a Subject
/api/v1.0/subjects/needs: add subject permission
| Name | Default | Notes |
|---|---|---|
|
string required
|
Doesn't have to be unique. | |
|
string required
|
An existing subtype value, e.g. elephant. |
|
|
object optional
|
{} |
Any extra values. |
|
boolean optional
|
true |
cURL
curl -X POST "https://sandbox.pamdas.org/api/v1.0/subjects/" \
-H "Authorization: Bearer {{TOKEN}}" \
-H "Content-Type: application/json" \
-d '{"name": "Kibo", "subject_subtype": "elephant", "additional": {"sex": "male"}}'Python
import requests
response = requests.post(
"https://sandbox.pamdas.org/api/v1.0/subjects/",
headers={"Authorization": "Bearer {{TOKEN}}"},
json={"name": "Kibo", "subject_subtype": "elephant", "additional": {"sex": "male"}},
timeout=30,
)
response.raise_for_status()
subject_id = response.json()["data"]["id"]er-client
subject = client.post_subject({"name": "Kibo", "subject_subtype": "elephant"})The new subject goes into the site's default group. To change it later, PATCH /api/v1.0/subject/{id}/ with name, subject_subtype, additional (replaced as a whole) or is_active.
Subject Groups
/api/v1.0/subjectgroups/needs: view subject group permission
Each group is {"id", "name", "subjects", "subgroups"}, with groups nested inside subgroups. flat=true returns them as one list; group_name finds a group by its exact name.
cURL
curl "https://sandbox.pamdas.org/api/v1.0/subjectgroups/?group_name=Elephants" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/subjectgroups/",
headers={"Authorization": "Bearer {{TOKEN}}"},
params={"group_name": "Elephants"},
timeout=30,
)
response.raise_for_status()
for group in response.json()["data"]:
print(group["name"], len(group["subjects"]))er-client
groups = client.get_subjectgroups(group_name="Elephants")To list one group's own members, call GET /api/v1.0/subjectgroup/{id}/subjects/. To add or remove subjects, send POST or DELETE to the same path with [{"id": "…"}].
Tracks
/api/v1.0/subject/{id}/tracks/needs: view permission on the subject
Returns a GeoJSON line of the subject's positions, newest first. coordinateProperties.times holds one time per point, in the same order.
| Name | Default | Notes |
|---|---|---|
|
datetime optional
|
the site's track days back (16 by default) | Earliest point. Limited by what the user may see. |
|
datetime optional
|
now | Latest point. |
|
integer optional
|
Keep only the newest N points. |
cURL
curl "https://sandbox.pamdas.org/api/v1.0/subject/2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f/tracks/?since=2026-10-25T00:00:00Z&until=2026-11-01T23:59:59Z" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/subject/2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f/tracks/",
headers={"Authorization": "Bearer {{TOKEN}}"},
params={"since": "2026-10-25T00:00:00Z", "until": "2026-11-01T23:59:59Z"},
timeout=30,
)
response.raise_for_status()
feature = response.json()["data"]["features"][0]
coordinates = feature["geometry"]["coordinates"] if feature["geometry"] else []
times = feature["properties"]["coordinateProperties"]["times"]
for (lon, lat), time in zip(coordinates, times):
print(time, lat, lon)er-client
from datetime import datetime, timezone
tracks = client.get_subject_tracks(
subject_id="2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
start=datetime(2026, 10, 25, tzinfo=timezone.utc), # must be a datetime
)200 OK
{
"data": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": [[34.86, -2.41], [34.85, -2.4]]},
"properties": {
"title": "Kibo",
"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"coordinateProperties": {
"times": ["2026-11-01T09:30:00+00:00", "2026-11-01T08:30:00+00:00"]
}
}
}
]
},
"status": {"code": 200, "message": "OK"}
}
List Observations
/api/v1.0/observations/needs: view observation permission
Returns single positions, oldest first, in pages.
| Name | Default | Notes |
|---|---|---|
|
UUID optional
|
Positions from the subject's sources while they were assigned to it. | |
|
UUID optional
|
One source. | |
|
UUID optional
|
One subject–source assignment. | |
|
UUID optional
|
All sources of one provider (its id, not its key). | |
|
datetime optional
|
24 hours before until with a source or provider id |
Earliest position. |
|
datetime optional
|
now | Latest position. |
|
boolean optional
|
false |
Adds observation_details (the additional values). |
|
string optional
|
west,south,east,north. |
|
|
boolean optional
|
false |
Cursor paging, for large exports. |
|
integer optional
|
25 | Maximum 4000. |
The id filters can be combined; each one narrows the result.
cURL
curl "https://sandbox.pamdas.org/api/v1.0/observations/?subject_id=2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f&since=2026-11-01T00:00:00Z&include_details=true&page_size=100" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
url = "https://sandbox.pamdas.org/api/v1.0/observations/"
headers = {"Authorization": "Bearer {{TOKEN}}"}
params = {
"subject_id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"since": "2026-11-01T00:00:00Z",
"include_details": "true",
"page_size": 100,
}
while url:
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
page = response.json()["data"]
for observation in page["results"]:
print(observation["recorded_at"], observation["location"])
url = page["next"]
params = None # the next URL already holds every parameterer-client
from datetime import datetime, timezone
for observation in client.get_observations(
subject_id="2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
start=datetime(2026, 11, 1, tzinfo=timezone.utc),
):
print(observation["recorded_at"])200 OK
{
"data": {
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": "e4b7c2a1-3d5f-4e6a-8b9c-0d1e2f3a4b5c",
"location": {"latitude": -2.4, "longitude": 34.85},
"created_at": "2026-11-01T08:31:02.114233Z",
"recorded_at": "2026-11-01T08:30:00Z",
"source": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"exclusion_flags": 0,
"observation_details": {"speed_kmph": 1.2}
}
]
},
"status": {"code": 200, "message": "OK"}
}Patrols
A patrol holds one or more segments. The patrol type, leader, times and linked events are on each segment, not on the patrol itself.
List Patrols
/api/v1.0/activity/patrols/needs: view patrol permission
| Name | Default | Notes |
|---|---|---|
|
boolean optional
|
false |
Cursor paging, newest first. Recommended. |
|
integer optional
|
25 | Maximum 4000. |
|
string, repeat optional
|
all |
scheduled, active, overdue, done or cancelled. Repeat for several. |
|
JSON optional
|
See the filter keys below. Send valid JSON. | |
|
boolean optional
|
false |
Leave out patrols with no segments. |
|
boolean optional
|
true |
Include the change history. |
|
boolean optional
|
true |
Include notes. |
|
boolean optional
|
true |
Include files. |
|
boolean optional
|
true |
Include each segment's events. |
| Key | Value | Matches |
|---|---|---|
date_range |
{"lower": …, "upper": …} |
Patrols whose segments overlap the range (see the next key). |
patrols_overlap_daterange |
boolean, default true
|
false: only patrols with a segment that starts inside the range. |
patrol_type |
list of UUIDs | Patrol type ids, not values like routine_patrol. |
text |
string | Serial number, title, patrol type name, notes or leader name. |
tracked_by |
list of UUIDs | Leader ids (subject or user). |
cURL
curl -G "https://sandbox.pamdas.org/api/v1.0/activity/patrols/" \
-H "Authorization: Bearer {{TOKEN}}" \
--data-urlencode 'filter={"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"}, "patrol_type": ["7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"]}' \
--data-urlencode "status=active" \
--data-urlencode "include_segment_events=false" \
--data-urlencode "use_cursor=true" \
--data-urlencode "page_size=100"Python
import json
import requests
params = {
"filter": json.dumps({
"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"},
"patrol_type": ["7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"],
}),
"status": "active",
"include_segment_events": "false",
"use_cursor": "true",
"page_size": 100,
}
patrols = []
url = "https://sandbox.pamdas.org/api/v1.0/activity/patrols/"
while url:
response = requests.get(url, headers={"Authorization": "Bearer {{TOKEN}}"}, params=params, timeout=30)
response.raise_for_status()
page = response.json()["data"]
patrols.extend(page["results"])
url = page["next"]
params = None
for patrol in patrols:
print(patrol["serial_number"], patrol["title"], patrol["state"])er-client
import json
patrol_filter = json.dumps({
"date_range": {"lower": "2026-11-01T00:00:00Z", "upper": "2026-11-02T00:00:00Z"},
"patrol_type": ["7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"],
})
# get_patrols can't filter by status: filter the results yourself
for patrol in client.get_patrols(filter=patrol_filter, page_size=100):
print(patrol["serial_number"], patrol["state"])Get One Patrol
/api/v1.0/activity/patrols/{id}/needs: view patrol permission
Takes the same include_… flags as the list.
cURL
curl "https://sandbox.pamdas.org/api/v1.0/activity/patrols/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/" \
-H "Authorization: Bearer {{TOKEN}}"Python
import requests
response = requests.get(
"https://sandbox.pamdas.org/api/v1.0/activity/patrols/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/",
headers={"Authorization": "Bearer {{TOKEN}}"},
timeout=30,
)
response.raise_for_status()
patrol = response.json()["data"]
segment = patrol["patrol_segments"][0]
print(patrol["serial_number"], segment["patrol_type"], segment["time_range"])200 OK
{
"data": {
"id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"serial_number": 215,
"title": "Morning river patrol",
"objective": "Check the river crossing for elephant activity",
"priority": 0,
"state": "open",
"last_activity_at": "2026-11-01T09:15:00Z",
"notes": [],
"files": [],
"patrol_segments": [
{
"id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
"patrol_type": "routine_patrol",
"leader": {
"content_type": "observations.subject",
"id": "2d9e4f61-7a3b-4c5d-8e9f-0a1b2c3d4e5f",
"name": "Kibo"
},
"scheduled_start": "2026-11-01T08:00:00Z",
"scheduled_end": "2026-11-01T12:00:00Z",
"time_range": {"start_time": "2026-11-01T08:30:00Z", "end_time": null},
"start_location": {"latitude": -2.4, "longitude": 34.85},
"end_location": null,
"events": [
{
"id": "8f3b2c1d-4e5a-4b6c-9d7e-1a2b3c4d5e6f",
"serial_number": 1042,
"event_type": "wildlife_sighting_rep",
"title": "Elephant herd near the river"
}
],
"updates": []
}
],
"updates": []
},
"status": {"code": 200, "message": "OK"}
}
(Trimmed.) A patrol's state is open, done or cancelled. scheduled, active and overdue exist only as status filters, worked out from the segment times.
More Patrol Endpoints
| Path | Methods | What it does |
|---|---|---|
/activity/patrols/segments/ |
GET, POST | Lists or creates segments. |
/activity/patrols/segments/{id}/ |
GET, PATCH, DELETE | One segment. |
/activity/patrols/segments/{id}/events/ |
GET, POST | Events linked to a segment. |
/activity/patrols/types/ |
GET | Patrol types, with their ids for the patrol_type filter. |
/activity/patrols/{id}/notes/ |
GET, POST | Notes on a patrol. |
/activity/patrols/{id}/files/ |
GET, POST | Files on a patrol. |
All paths start with /api/v1.0.
Errors
Errors use standard HTTP codes. Most error bodies hold the reason in status.detail, or list the fields that failed next to status.
| Response | What it means | What to do |
|---|---|---|
| 400 Bad Request | A field or parameter is wrong. The body names it. | Fix what the body names and resend. |
| 401 Unauthorized | The token is missing, wrong or expired. | Check the Authorization: Bearer header, or get a new token. |
| 403 Forbidden | The token's user lacks permission, for example on that event category or subject group. | Ask your site admin for the permission. |
| 404 Not Found | The path is misspelled, or the id doesn't exist or isn't visible to the user. | Check the path and the id. |
| 409 Conflict | The change clashes with existing data, e.g. deleting an event type that is in use, or a position already stored. | Read data.detail or data.error_message, then fix the clash. |
| 429 Too Many Requests | Too many events were created too fast. | Wait for the seconds in Retry-After, then resend. Send smaller lists. |
| 500 Internal Server Error | Something failed on the server. Invalid JSON in a filter is a common cause. |
Check your input, then contact support with the time and the URL. |
401 Unauthorized
{
"status": {
"code": 401,
"message": "Unauthorized",
"detail": "Authentication credentials were not provided."
}
}
400 Bad Request
{
"event_type": ["This field is required."],
"status": {"code": 400, "message": "Bad Request"}
}Questions about permissions, tokens or a specific endpoint? Our team can help.
