EarthRanger Client
Introduction
EarthRanger is a software solution that helps protected area managers, ecologists, and wildlife biologists make informed operational decisions for wildlife conservation.
The earthranger-client (er-client) is a Python library for accessing the EarthRanger HTTP API. It simplifies interaction with the API by abstracting resource-based endpoints and offers both synchronous and asyncio clients, plus multi-threaded helpers for bulk reads.
Uses of er-client
- Extracting data for analysis
- Importing ecological or other historical data
- Integrating a new field sensor type. If you do and will be supporting multiple ER sites, contact us to talk about our Gundi integrations platform
- Performing external analysis that results in publishing an Alert on the ER platform.
Quick Start
See docs/examples/simple-example.py for a full sync example (pulse, subjects, tracks, create event, attach file, query events).
Installation
From PyPI:
pip install earthranger-client
Choosing sync vs async
| Use case | Client | Notes |
|---|---|---|
| Scripts, notebooks, one-off jobs | ERClient (sync) |
Blocking calls; no event loop. |
| Asyncio apps (e.g. web servers, async pipelines) | AsyncERClient (async) |
Use async with or call close() when done. |
Both clients take the same constructor arguments, apart from two async-only timeouts (see "Constructor arguments" below). The async client supports a subset of the sync client's endpoints (see "Async client scope" below).
Sync client (ERClient)
Import and construct with service_root and either username/password (+ client_id) or a bearer token:
from erclient import ERClient
# Username/password (client_id required)
client = ERClient(
service_root="https://sandbox.pamdas.org",
client_id="example_client_id",
username="your_username",
password="your_password",
provider_key="your_provider_key", # only needed for sensor / camera-trap posts
)
# Or with a bearer token
client = ERClient(service_root="https://sandbox.pamdas.org", token="your_bearer_token")
Common patterns:
import json
from datetime import datetime, timezone
# Single item
event = client.get_event(event_id="uuid")
subject = client.get_subject(subject_id="uuid")
# Paginated iteration (generators).
# `filter` must be a JSON-encoded string, not a dict.
event_filter = json.dumps({
"date_range": {
"lower": "2023-11-10T00:00:00-06:00",
"upper": "2023-11-11T00:00:00-06:00",
},
})
for event in client.get_events(filter=event_filter, max_results=100):
...
for obs in client.get_observations(
start=datetime(2023, 11, 10, tzinfo=timezone.utc),
end=datetime(2023, 11, 11, tzinfo=timezone.utc),
):
...
# Create / update
new_event = client.post_report({
"event_type": "wildlife_sighting_rep", # must match an event type in your ER site
"title": "A new event",
"location": {"latitude": 47.5978393, "longitude": -122.3308366},
})
client.post_sensor_observation(observation, sensor_type="generic") # requires provider_key
client.post_event_file(event_id, filepath="/path/to/file", comment="...")
For bulk reads the sync client also provides get_objects_multithreaded(object="observations", ...).
Async client (AsyncERClient)
Use an async context manager so the HTTP session is always closed:
import asyncio
import json
from erclient import AsyncERClient
async def main():
async with AsyncERClient(
service_root="https://sandbox.pamdas.org",
client_id="example_client_id",
username="your_username",
password="your_password",
provider_key="your_provider_key", # only needed for sensor / camera-trap posts
) as client:
# Single-item calls: await
event = await client.get_event(event_id="uuid")
event_types = await client.get_event_types()
# Stream events or observations: async for.
# `filter` must be a JSON-encoded string, not a dict.
event_filter = json.dumps({"date_range": {"lower": "2023-11-10T00:00:00-06:00"}})
async for event in client.get_events(filter=event_filter, page_size=100):
...
async for observation in client.get_observations(start="2023-11-10T00:00:00-06:00"):
...
# Post (await)
await client.post_sensor_observation(position)
await client.post_report(report)
await client.post_camera_trap_report(camera_trap_payload, file=file_handle)
asyncio.run(main())
Without a context manager, create the client and call await client.close() when finished:
async def main():
client = AsyncERClient(service_root="...", client_id="...", username="...", password="...")
try:
await client.post_report(report)
async for obs in client.get_observations(start="2023-11-10T00:00:00-06:00"):
print(obs)
finally:
await client.close()
asyncio.run(main())
Async client scope
The async client currently supports:
- Post: Sensor observations (positions), events/reports, event attachments, camera trap reports, messages, event types, event categories; adding subjects to a subject group.
- Get: Events, single event, event types, event categories, observations, subject groups, subject sources, feature groups, sources (by manufacturer id), source assignments (subjectsources), user/me.
- Patch: Events, reports, subjects, event types, event categories.
- Delete: Events, event files, event notes, subjects, sources. Neither client can delete event types or event categories.
- Relationships: Adding/removing events to and from incidents; removing subjects from a subject group.
For the full sync surface (e.g. patrols, tracking data export, multithreaded bulk), use ERClient.
Constructor arguments
Both clients are declared as __init__(self, **kwargs), so every argument is
keyword-only — ERClient("https://sandbox.pamdas.org") raises TypeError.
Unrecognised keywords are silently ignored rather than rejected, so a typo such as
provider_ke= leaves provider_key unset with no error.
| Argument | Default | Notes |
|---|---|---|
service_root |
None |
Base URL, e.g. https://sandbox.pamdas.org. A full API root is also accepted: any /api/... suffix is stripped, so passing .../api/v2.0 does not select v2.0. |
client_id |
None |
Required for username/password auth. |
username, password |
None |
Use together with client_id, or pass token instead. |
token |
None |
Bearer token. Skips the OAuth2 password grant entirely. |
provider_key |
None |
Required for sensor and camera-trap posts; it becomes a path segment. |
token_url |
{service_root}/oauth2/token |
Override only if the auth endpoint differs. |
max_http_retries |
5 |
Connection-level retries. Effective on async only — the sync client accepts and stores it but never uses it; sync retry behavior is fixed (5 session-level retries on 502, plus per-request retries in GETs). |
realtime_url |
None |
Accepted and stored, but unused by this library. |
connect_timeout |
3.1 |
Seconds. Async only. |
data_timeout |
20 |
Seconds. Async only. |
Use either token, or client_id + username + password.
API versions
Event-type endpoints exist in two API versions. v1.0 is the default; pass version=
to opt into v2.0:
from erclient import ERClient, VERSION_2_0
client = ERClient(service_root="https://sandbox.pamdas.org", token="your_bearer_token")
event_types = client.get_event_types(version=VERSION_2_0)
Four methods accept version=, on both clients — get_event_types, get_event_type,
post_event_type, patch_event_type. Every other call is v1.0, and passing
.../api/v2.0 as service_root does not change that (see "Constructor arguments").
Accepted values are VERSION_1_0 ("v1.0") and VERSION_2_0 ("v2.0"); the aliases
"v1" and "v2" work too. Anything else raises ValueError, including "1.0" — the
leading v is required.
patch_event_type identifies the event type differently per version, and raises
ValueError when the key it needs is absent from the payload:
| Version | Key used | Resulting path |
|---|---|---|
v1.0 |
event_type["id"] |
activity/events/eventtypes/{id} |
v2.0 |
event_type["value"] (slug) |
activity/eventtypes/{value} |
Caveat: the async client's patch_event_type reads event_type["value"] regardless of
version (for logging), so on AsyncERClient a v1.0 payload must include value as
well as id — a payload without value raises KeyError there, not ValueError.
Common method signatures (reference)
-
Events:
get_events(*, filter, page_size, max_results, ...)→ sync: generator; async: async generator.
get_event(*, event_id, include_details, include_notes, ...)→ single dict.
post_report(data)/post_event(data)→ created resource. -
Observations:
get_observations(*, subject_id, source_id, start, end, page_size, ...)→ sync: generator; async: async generator.
post_sensor_observation(observation, sensor_type='generic')→ requiresprovider_key. -
Single resources:
get_subject(subject_id),get_source_by_id(id),get_event_type(event_type_name, version=...), etc. return one object.
Sync vs async differences
Behaviors that are not shared, despite the common signatures above:
| Behavior | ERClient (sync) |
AsyncERClient (async) |
|---|---|---|
get_observations start/end |
datetime only — ISO strings are silently ignored |
datetime or ISO 8601 string |
get_events max_results |
honored client-side | ignored (forwarded as a query param) |
get_observations page_size default |
10000 | 100 |
| HTTP 409 / 429 | plain ERClientException |
ERClientRateLimitExceeded, with retry_after |
| HTTP error → exception subclass | only 403 / 404 are consistent; other codes often raise plain ERClientException, and 401 / 502 / 504 vary by method |
common statuses (400, 401, 403, 404, 409, 429, 500, 502, 503, 504) mapped to subclasses; others raise plain ERClientException |
exc.status_code / exc.response_body / exc.retry_after |
never set (always None); the status is recoverable only from the exception type, or from the message text for unmapped codes |
populated on every HTTP error |
| Helpers only on one client | get_subject, get_source_by_id, get_sources, get_subjects, pulse |
get_feature_group, get_source_subjects, get_source_assignments |
Best practices
- Async: Prefer
async with AsyncERClient(...) as client:so the session is closed even on errors. - Errors: Catch
ERClientException; every client error subclasses it. Async maps common statuses to specific subclasses (ERClientBadRequest,ERClientBadCredentials,ERClientPermissionDenied,ERClientNotFound,ERClientRateLimitExceeded,ERClientInternalError,ERClientServiceUnreachable); unmapped statuses raiseERClientExceptionitself, still withstatus_codeset. Sync maps only 403 and 404 consistently, and sync-raised exceptions never populateexc.status_code(it is alwaysNone). For the codes sync does map, the exception type is the only signal — a 404 raisesERClientNotFoundwith no message at all — and the numeric status reaches the message text only for unmapped codes. There is no reliable way to branch on status with the sync client. - Time ranges: Pass timezone-aware
datetimeforstart/end— correct on both clients. Sync silently ignores ISO strings there; async accepts them. Filterdate_rangebounds are ISO 8601 strings with timezone, e.g."2023-11-10T00:00:00-06:00". - Sensor/camera-trap posts: Set
provider_keyon the client when posting sensor observations or camera trap reports. - Large reads: Sync: consider
get_objects_multithreadedfor big list endpoints. Async: usepage_sizeand optionalbatch_sizeinget_events/get_observations; cursor-based pagination is used by default. - Rate limits: The API may throttle (e.g. one observation per second per source). Async maps 409/429 to
ERClientRateLimitExceeded, withexc.retry_afterin seconds; sync raises a plainERClientExceptionwithexc.status_codeunset — the status appears only in the message text.
For more on the EarthRanger API and event types, see EarthRanger and your ER instance's API documentation.
Release files for earthranger-client 1.17.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| earthranger_client-1.17.0.tar.gz | 205.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| earthranger_client-1.17.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 237.4 kB
Release files / earthranger_client-1.17.0.tar.gz
| Download URL | earthranger_client-1.17.0.tar.gz |
|---|---|
| Size | 205.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4fa58ae6ce64f10d646c4ec560a06cb6a6185a990b870e402b9b1d938083c960
|
|
BLAKE2b-256 checksum How to use checksums |
1cf7e1690f08a4e4f24aa490fe196446e4681b075fd828324dd7efb93a7e5898
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency logRelease files / earthranger_client-1.17.0-py3-none-any.whl
| Download URL | earthranger_client-1.17.0-py3-none-any.whl |
|---|---|
| Size | 31.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3e5b32242335c24de173995664207e414d89b553e8c292cb49cd3e422a87c266
|
|
BLAKE2b-256 checksum How to use checksums |
c33475695fda8b02fa0dad13108b4f7fa2cfd58627f5911c5b21e59b74d10157
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.
Transparency log