Interact with EarthRanger using the API

Last Updated: November 06, 2026

Read and manage your site's events, event types, subjects, tracks and patrols from your own scripts, dashboards and integrations.

Quick Start

  1. Get a tokenAsk your site admin for a long-lived token (how-to), or sign in for one with OAuth2 (see Authentication).
  2. Make one callList the subjects your token can see. Replace sandbox in the samples with your site's name.
  3. 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

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

GET /api/v1.0/activity/events/needs: read permission on the category
Name Default Notes

use_cursor

boolean optional
false true turns on cursor paging, newest first. Recommended.

page_size

integer optional
25 Maximum 4000.

state

string, repeat optional
all new, active, resolved or review. Repeat for several: state=new&state=active.

event_type

UUID, repeat optional
all The event type's id, not its value.

event_category

string, repeat optional
all Category value, e.g. monitoring.

event_ids

UUID, repeat optional
  Only these events.

updated_since

datetime optional
  Events changed at or after this time. Good for syncing.

bbox

string optional
  west,south,east,north.

filter

JSON optional
  See the filter keys below.

is_collection

boolean optional
  true: only collections; false: no collections.

exclude_contained

boolean optional
  true leaves out events inside a collection. Don't combine with is_collection.

sort_by

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.

include_details

boolean optional
true Include event_details.

include_updates

boolean optional
true Include the change history.

include_files

boolean optional
true Include attached files.

include_notes

boolean optional
false Include notes.

include_related_events

boolean optional
false Include linked and contained events.
Filter keys
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

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

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

event_type

string required
  The event type's value, e.g. wildlife_sighting_rep.

time

datetime optional
now When it happened.

location

object optional
  {"latitude": …, "longitude": …}. For polygon event types, send geometry (GeoJSON) instead.

title

string optional
event type name Shown in the event feed.

priority

integer optional
event type default 0 gray, 100 green, 200 amber, 300 red.

state

string optional
event type default new, active, resolved or review.

event_details

object optional
{} The form's fields, keyed by the names in the event type's schema.

reported_by

object optional
  {"content_type": "accounts.user", "id": "…"}, or observations.subject for a subject.

related_subjects

list optional
  Subjects linked to the event: [{"id": "…"}].

notes

list optional
  [{"text": "…"}].

patrol_segments

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

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

GET /api/v2.0/activity/eventtypes/needs: read permission on the category
Name Default Notes

category

string optional
all One or more category values, comma-separated: monitoring,security.

include_inactive

boolean optional
false Also return inactive types.

include_schema

boolean optional
false Add each type's schema (as a JSON string).

updated_since

datetime optional
  Types changed at or after this time.

is_collection

boolean optional
  Only collection types, or none.

sort_by

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.0
 
 
200 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

GET /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.

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

POST /api/v2.0/activity/eventtypes/
Name Default Notes

value

string required
  Letters, digits, - and _; saved in lowercase. Unique on the site.

category

string required
  Category value, e.g. monitoring.

schema

object required
  The form: json (the fields) and ui (the layout). See the sample.

display

string optional
"" Name shown in the apps.

default_priority

integer optional
0 0, 100, 200 or 300.

default_state

string optional
new new, active, resolved or review.

geometry_type

string optional
Point Point or Polygon.

auto_resolve

boolean optional
false Resolve events automatically after resolve_time.

resolve_time

integer optional
null In hours. Set it only when auto_resolve is true.

is_collection

boolean optional
false Events of this type can group other events.

is_active

boolean optional
true false hides the type.

icon

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

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

er-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

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

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

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

manufacturer_id

string required
  The device's id. Unique per provider.

recorded_at

datetime required
  When the position was taken, with a timezone.

location

object required
  {"lat": …, "lon": …} (not latitude/longitude).

subject_name

string optional
the manufacturer_id Name of the new subject.

subject_subtype

string optional
ranger Subtype value, e.g. elephant.

subject_groups

list optional
site default group Subject group names. Missing groups are created.

subject_id

UUID optional
  Link the device to this existing subject instead of creating one.

source_type

string optional
  tracking-device, trap, seismic, firms or gps-radio.

additional

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 stored
 
 

er-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

POST /api/v1.0/observations/needs: add observation permission
Name Default Notes

source

UUID required
  The id of an existing source.

recorded_at

datetime required
  With a timezone.

location

object required
  {"latitude": …, "longitude": …}.

additional

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

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

subject_group

UUID optional
  One group and the groups inside it. Several ids, comma-separated: subjects in any of them.

id

UUIDs optional
  Only these subjects, comma-separated.

name

string optional
  Name contains this text (any case).

subject_subtypes

string optional
  Subtype values, comma-separated: elephant,rhino.

subject_type

string optional
  Type value, e.g. wildlife.

additional.<key>

string optional
  Exact match on a key in additional, e.g. additional.sex=female.

include_inactive

boolean optional
false Also return inactive subjects.

updated_since

datetime optional
  Subjects changed since. Don't combine with position_updated_since.

position_updated_since

datetime optional
  Subjects whose position changed since.

bbox

string optional
  Subjects with track points in this box.

tracks

boolean optional
false Add each subject's recent track. Narrow it with tracks_since, tracks_until, tracks_limit.

render_last_location

boolean optional
true false leaves out last_position.

page_size

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

POST /api/v1.0/subjects/needs: add subject permission
Name Default Notes

name

string required
  Doesn't have to be unique.

subject_subtype

string required
  An existing subtype value, e.g. elephant.

additional

object optional
{} Any extra values.

is_active

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

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

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

since

datetime optional
the site's track days back (16 by default) Earliest point. Limited by what the user may see.

until

datetime optional
now Latest point.

limit

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

GET /api/v1.0/observations/needs: view observation permission

Returns single positions, oldest first, in pages.

Name Default Notes

subject_id

UUID optional
  Positions from the subject's sources while they were assigned to it.

source_id

UUID optional
  One source.

subjectsource_id

UUID optional
  One subject–source assignment.

sourceprovider_id

UUID optional
  All sources of one provider (its id, not its key).

since

datetime optional
24 hours before until with a source or provider id Earliest position.

until

datetime optional
now Latest position.

include_details

boolean optional
false Adds observation_details (the additional values).

bbox

string optional
  west,south,east,north.

use_cursor

boolean optional
false Cursor paging, for large exports.

page_size

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 parameter
 
 

er-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

GET /api/v1.0/activity/patrols/needs: view patrol permission
Name Default Notes

use_cursor

boolean optional
false Cursor paging, newest first. Recommended.

page_size

integer optional
25 Maximum 4000.

status

string, repeat optional
all scheduled, active, overdue, done or cancelled. Repeat for several.

filter

JSON optional
  See the filter keys below. Send valid JSON.

exclude_empty_patrols

boolean optional
false Leave out patrols with no segments.

include_updates

boolean optional
true Include the change history.

include_notes

boolean optional
true Include notes.

include_files

boolean optional
true Include files.

include_segment_events

boolean optional
true Include each segment's events.
Filter keys
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

GET /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.
Examples
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"}
}
Need a hand?

Questions about permissions, tokens or a specific endpoint? Our team can help.

Contact Support

Checked against EarthRanger 2.149.1 · Last update: Nov 5, 2026