PUBLIC DATA // HUMANITARIAN USE
ISSACHAR / Developers

GroundTruth API

Read vetted records of attacks, illegal mining, occupied farmland, displacement and road access as JSON or GeoJSON, follow every change in order, and write to your organization’s datasets from your own systems.

Base URL https://issacharanalytics.org/api/v1
Quickstart

From nothing to a live copy of a dataset

The API is for systems: your database, a partner’s research tool, a coalition dashboard. Each system gets its own key, reads what your organization is allowed to read, and follows changes in order so its copy stays correct.

1
Get access for your organization

Keys belong to an organization on ISSACHAR, not to a person. If yours isn’t on the platform yet, request access. Every organization starts on the Community plan, which includes the feed API.

2
Create a feed client and copy its token

An org admin opens Dashboard → GroundTruth → Feed Clients and clicks New Feed Client. A feed client is one connected system. Give it:

  • A name, such as “Partner GIS sync”, and who runs it.
  • A token lifetime: 30, 90, 180 or 365 days.
  • Access: every token can read (datasets:read). Tick “Can also write to our datasets” to add datasets:write.

The token is shown once. Copy it into your system’s secret store. If it’s lost, rotate the client to get a new one; the old token stops working straight away.

3
Make your first call

Send the token as a bearer token. GET /datasets lists every dataset your organization can read, with its fields and the head of its audit chain.

ShellGET /datasets
export GT=https://issacharanalytics.org/api/v1
export GT_TOKEN="…"   # from Feed Clients, shown once

curl -s "$GT/datasets" -H "Authorization: Bearer $GT_TOKEN"
Response (shortened)200 OK
{
  "data": [
    {
      "id": "01JP9ZK4…",
      "name": "Benue attacks",
      "type": "attacks",
      "visibility": "public",
      "is_own": true,
      "public_geo_precision": "community",
      "shared_with_you": null,
      "fields": [ … ],
      "head": { "feed_seq": 1842, "audit_seq": 2210, "hash": "9f2c…6e41", … }
    }
  ]
}

Filter with type=attacks, or scope=own / scope=others.

4
Read records

GET /datasets/{id}/records returns current records, newest event first. Retracted and merged records are left out unless you ask for them by status.

  • occurred_after, occurred_before: when the event happened.
  • bbox=west,south,east,north in degrees.
  • status=verified,disputed, or all.
  • min_rating=B2: see ratings.
  • format=geojson for a FeatureCollection; compact=1 for map-sized rows.
  • as_of: the records as the platform knew them at that moment.
  • per_page: 100 by default, at most 500 (2,000 with compact).

Pages come with next_cursor and next_url. Follow next_url, which keeps your filters, until it is null. GET /records/{id} returns one record with its sources and, for attacks, casualty reports, named victims (masked) and attributions. /records/{id}/history returns every version.

ShellGET /datasets/{id}/records
# Verified or disputed records rated B2 or better, in a box over Benue, as GeoJSON
curl -s -H "Authorization: Bearer $GT_TOKEN" \
  "$GT/datasets/$DATASET/records?status=verified,disputed&min_rating=B2&bbox=7.6,6.4,10.0,8.2&format=geojson"

# The same dataset as your organization knew it on 1 April
curl -s -H "Authorization: Bearer $GT_TOKEN" \
  "$GT/datasets/$DATASET/records?as_of=2026-04-01T00:00:00Z"
5
Follow changes

GET /feed returns every change, in commit order, across the datasets you can read. Start without a cursor to replay the whole history, or with from=latest to start at the current heads (for example after loading records in bulk; replaying a change you already hold does no harm).

Pass next_cursor back until has_more is false, store the last cursor, and poll with it. The cursor is signed and only works for your organization. Narrow the feed with dataset (comma-separated ids) or type; limit is 100 by default, at most 500. A dataset you gain access to later is replayed from its start.

Polling loop (shell + jq)GET /feed
# First run: start at the current heads
cursor=$(curl -s -H "Authorization: Bearer $GT_TOKEN" "$GT/feed?from=latest" | jq -r .next_cursor)

while true; do
  page=$(curl -s -H "Authorization: Bearer $GT_TOKEN" "$GT/feed?cursor=$cursor&limit=500")
  echo "$page" | jq -c '.data[]' | apply_entry    # your code, in order
  cursor=$(echo "$page" | jq -r .next_cursor)
  save_cursor "$cursor"                              # only after applying the page
  [ "$(echo "$page" | jq .has_more)" = true ] || sleep 60
done
Applying entries (pseudo-code)op
for entry in page.data:                  # ordered by dataset, then seq
  switch entry.op:
    "upsert":         store(entry.record, entry.data)       # last upsert = current state
    "tombstone":      remove(entry.record)                  # retracted, merged or withdrawn
    "link":           refetch("/records/" + entry.record)
    "dataset_policy": handle_policy(entry.dataset, entry.reason)   # may mean purge

Each reason and what to do with it is in Feed entries.

6
Write to your organization’s datasets

Tokens with datasets:write can add and change records in your own organization’s datasets, like an analyst, with the same checks as the dashboard. Every write lands on the audit chain.

  • POST /datasets/{id}/records needs title, date, date_precision and location_precision. Dates are local to the dataset’s timezone. Send your own id as external_ref: sending it again returns 409 with the existing record’s id, never a duplicate.
  • PATCH /records/{id} replaces the fields you send, merges properties key by key, and needs a change_reason. Nothing changed means no new version ("changed": false).
  • POST /records/{id}/status verifies, disputes, retracts or restores, with a reason. A feed client can only retract records it created.
  • Sources (with a screenshot or PDF as multipart file), casualty claims, named victims, attributions and observations each have a POST, and a DELETE that needs a reason.
ShellPOST /datasets/{id}/records
curl -s -X POST "$GT/datasets/$DATASET/records" \
  -H "Authorization: Bearer $GT_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "title": "Attack by Fulani militants in Guma (Benue)",
    "date": "2026-03-14", "date_precision": "day",
    "lat": 7.83, "lng": 8.74, "location_precision": "community",
    "country": "NG", "admin1": "Benue", "admin2": "Guma",
    "properties": { "attack_type": "armed_assault", "actor_category": "fulani_linked" },
    "external_ref": "partner-db:48213"
  }'
ShellPATCH · POST status · DELETE
# Revise: a new version, with the reason on the record's history
curl -s -X PATCH "$GT/records/$RECORD" -H "Authorization: Bearer $GT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "reliability": "B", "credibility": 2, "change_reason": "Field team confirmed named victims" }'

# Add one source's casualty figure ("at least 20" = no count_max)
curl -s -X POST "$GT/records/$RECORD/claims" -H "Authorization: Bearer $GT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "category": "killed", "count_min": 20, "claim_basis": "reported" }'

# Remove a source citation: it is closed, and stays in the history
curl -s -X DELETE "$GT/records/$RECORD/sources/$ITEM" -H "Authorization: Bearer $GT_TOKEN" \
  -H "Content-Type: application/json" -d '{ "reason": "Duplicate of the wire story" }'
Concepts

What the data means

What a client can read

A feed client reads what its organization reads, no more:

  • Your organization’s datasets, in full: drafts, exact locations, internal sources, notes and named victims.
  • Other organizations’ public datasets, masked: no drafts, locations generalized to the dataset’s public precision, public sources only, and named victims only when the dataset publishes names (otherwise persons is null and named_count gives the number).
  • Datasets shared with your organization (shared_with_you on the dataset), masked the same way but at the precision the share grants, with names when it includes them.

Other organizations never see drafts, internal sources, notes, or who on a team made a change.

Location precisionPoints snap toRoughlyNotes
exactAs recorded—The owner, and partners whose share grants it; shapes too
community0.01° grid1 kmDefault for mining, farmland, displacement and access datasets
admin20.1° grid11 kmLGA / district
admin10.5° grid55 kmState
countryNo point—Country only

Finer place names than the precision allows are dropped, and a bbox on another organization’s dataset is widened to the same grid, so searching smaller and smaller boxes can’t recover an exact point.

Two kinds of time
occurred_after and occurred_before filter by when the event happened. as_of asks for what the platform knew at a moment, before later corrections. Both are UTC, ISO 8601. An event’s when is a window covering its date precision.
Ratings
The Admiralty Code: source reliability A (completely reliable) to F (can’t be judged), information credibility 1 (confirmed) to 6 (can’t be judged). min_rating=B2 keeps records rated B or better and 2 or better, so F and 6 only pass a threshold that names them.
Casualties, never averaged
Per category (killed, injured, abducted, missing, displaced), floor is the confirmed minimum (verified named victims, or the highest minimum among claims rated at or above the dataset’s threshold) and ceiling the highest figure reported. conflict flags reports that disagree, or names outnumbering every report. Example: floor 17, ceiling 43.
Chain heads
Every dataset carries head, and every feed page heads: the latest audit_seq and hash of each dataset’s hash-chained audit log. Keep them. They are an external anchor that the platform’s audit verification must keep reproducing.
Feed Entries

What each entry asks you to do

Each dataset’s seq has no gaps and follows commit order; apply entries in order. Entries come ordered by dataset, then seq; order across datasets means nothing. Upserts carry the record as that version recorded it, masked with the dataset’s current sharing rules.

opreasonDo thisMeaning
upsertnullStoreKeep data; the last upsert per record is its current state.
upsertredactedRefetchA name was redacted: refetch the record’s persons.
tombstoneretractedRemoveThe record was found to be wrong.
tombstonemergedRemoveA duplicate: see merged_into and canonical for the survivor.
tombstonewithdrawnRemoveMoved back to draft by its owner. It may come back.
linksame_as · corroborates · unlinkedRefetchThe record’s links changed; linked_record is the other record when you can see it.
dataset_policyvisibility_revokedPurge datasetYou can no longer read it. It leaves your cursor.
dataset_policyvisibility_restrictedPurge if you lost accessNow partners only. If you still read it, you are a partner: nothing to do.
dataset_policyaccess_revokedPurge datasetYour organization’s share ended.
dataset_policynames_unpublishedPurge namesDelete named persons you hold from it.
dataset_policygeo_precision_reducedRefetch locationsReplace locations you’re no longer entitled to.
dataset_policyvisibility_granted · geo_precision_increased · names_publishedRefetchYou may now get more detail.
dataset_policyarchived · unarchivedNoteThe dataset was archived or brought back.
Purge Means Delete
Purge signals protect people. When a dataset owner restricts access or stops publishing names, systems that follow the feed are expected to delete what they are no longer allowed to hold, not just stop updating it.
Limits and Errors

Rate limits and error responses

Requests are limited per organization, shared by all its feed clients, per minute. Authenticated responses carry X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get 429 with Retry-After in seconds.

PlanRequests a minuteActive feed clients
Community60Up to 3
Professional600Up to 25

Errors are always JSON on /api/v1, whatever the Accept header.

Validation error422
{
  "message": "This cursor is not valid. Start again without one, or with from=latest.",
  "errors": { "cursor": [ "This cursor is not valid. Start again without one, or with from=latest." ] }
}
StatusWhen
401Missing, expired or revoked token, or a token that isn’t a feed client token.
403The token lacks the ability (writing without datasets:write), or you can’t change this dataset or record.
404It doesn’t exist, or you can’t read it. The two look the same on purpose.
409A record with this external_ref is already in the dataset; existing is its id.
422Invalid input: errors lists the problems by field.
429Over your organization’s per-minute limit. Wait Retry-After seconds.
Keeping Tokens Safe

A token reads what your organization reads

  • Keep it on a server. Never put a token in a web page, a mobile app or a public repository.
  • One system, one client. A token is shown once; each feed client holds one token at a time.
  • Rotate on a schedule. Tokens last 30 to 365 days. Rotating issues a new token and the old one stops working at once.
  • Revoke what you don’t use. Revoking a client deletes its token for good. Creating, rotating and revoking are all on your organization’s audit chain.
  • Machines only. Feed client tokens work only on /api/v1. People use the dashboard, and signed-in sessions can’t open /api/v1.
Reference

Every endpoint, parameter and field is in the API reference, generated from the OpenAPI 3.1 specification.

PUBLIC DATA // HUMANITARIAN USE