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.
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.
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 adddatasets: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.
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.
export GT=https://issacharanalytics.org/api/v1 export GT_TOKEN="…" # from Feed Clients, shown once curl -s "$GT/datasets" -H "Authorization: Bearer $GT_TOKEN"
{
"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.
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,northin degrees.status=verified,disputed, orall.min_rating=B2: see ratings.format=geojsonfor a FeatureCollection;compact=1for 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 withcompact).
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.
# 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"
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.
# 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
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.
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}/recordsneedstitle,date,date_precisionandlocation_precision. Dates are local to the dataset’s timezone. Send your own id asexternal_ref: sending it again returns409with the existing record’s id, never a duplicate.PATCH /records/{id}replaces the fields you send, mergespropertieskey by key, and needs achange_reason. Nothing changed means no new version ("changed": false).POST /records/{id}/statusverifies, disputes, retracts or restores, with areason. 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 aPOST, and aDELETEthat needs areason.
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" }'
# 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" }'
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
personsis null andnamed_countgives the number). - Datasets shared with your organization (
shared_with_youon 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 precision | Points snap to | Roughly | Notes |
|---|---|---|---|
| exact | As recorded | — | The owner, and partners whose share grants it; shapes too |
| community | 0.01° grid | 1 km | Default for mining, farmland, displacement and access datasets |
| admin2 | 0.1° grid | 11 km | LGA / district |
| admin1 | 0.5° grid | 55 km | State |
| country | No 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.
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.min_rating=B2 keeps records rated B or better and 2 or better, so F and 6 only pass a threshold that names them.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.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.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.
| op | reason | Do this | Meaning |
|---|---|---|---|
| upsert | null | Store | Keep data; the last upsert per record is its current state. |
| upsert | redacted | Refetch | A name was redacted: refetch the record’s persons. |
| tombstone | retracted | Remove | The record was found to be wrong. |
| tombstone | merged | Remove | A duplicate: see merged_into and canonical for the survivor. |
| tombstone | withdrawn | Remove | Moved back to draft by its owner. It may come back. |
| link | same_as · corroborates · unlinked | Refetch | The record’s links changed; linked_record is the other record when you can see it. |
| dataset_policy | visibility_revoked | Purge dataset | You can no longer read it. It leaves your cursor. |
| dataset_policy | visibility_restricted | Purge if you lost access | Now partners only. If you still read it, you are a partner: nothing to do. |
| dataset_policy | access_revoked | Purge dataset | Your organization’s share ended. |
| dataset_policy | names_unpublished | Purge names | Delete named persons you hold from it. |
| dataset_policy | geo_precision_reduced | Refetch locations | Replace locations you’re no longer entitled to. |
| dataset_policy | visibility_granted · geo_precision_increased · names_published | Refetch | You may now get more detail. |
| dataset_policy | archived · unarchived | Note | The dataset was archived or brought back. |
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.
| Plan | Requests a minute | Active feed clients |
|---|---|---|
| Community | 60 | Up to 3 |
| Professional | 600 | Up to 25 |
Errors are always JSON on /api/v1, whatever the Accept header.
{
"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." ] }
}
| Status | When |
|---|---|
| 401 | Missing, expired or revoked token, or a token that isn’t a feed client token. |
| 403 | The token lacks the ability (writing without datasets:write), or you can’t change this dataset or record. |
| 404 | It doesn’t exist, or you can’t read it. The two look the same on purpose. |
| 409 | A record with this external_ref is already in the dataset; existing is its id. |
| 422 | Invalid input: errors lists the problems by field. |
| 429 | Over your organization’s per-minute limit. Wait Retry-After seconds. |
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.
Every endpoint, parameter and field is in the API reference, generated from the OpenAPI 3.1 specification.