pycitizen
Async-first Python SDK for Citizen's public, unauthenticated incident API — geographic incident discovery via vector tiles, incident details, batch retrieval, related incidents, news feeds, and read-only chat history.
Reverse-engineered from Citizen Android 0.1308.0 (sp0n.citizen, build 1140). Every endpoint implemented here was verified reachable without an access token.
Status: experimental. Unofficial SDK, not affiliated with Citizen. The endpoints are undocumented and may change or become restricted at any time. Use politely — keep request rates low, cache aggressively, and honor the rate limiter defaults.
Features
- Geographic discovery — fetch every incident in a bounding box with one request per covering Mapbox vector tile (
/v1/tile/incidents/{x}/{y}/{z}.pbf), decoded by a built-in dependency-free MVT parser. - Incident details — v1/v2/v3 detail endpoints unified into one
Incidentmodel; batch retrieval for up to 50 IDs per request. - News — curated news feed and news briefings per service area.
- Auxiliary — service-area lookup, safety status, reverse geocoding, place search, incident content/map sources, read-only chat history, neighborhood trends (raw).
IncidentFeed— polling tracker with deduplication, field-level change detection, lifecycle-transition history, and graceful stale/removed handling. Designed to wrap in aDataUpdateCoordinator(e.g. Home Assistant).- Async-first —
aiohttp, injectable sessions, token-bucket rate limiting, bounded concurrency, retries with exponential backoff honoringRetry-After. - Fully typed —
py.typed, dataclass models, enum severity/lifecycle values matching the app's own DTOs.
Installation
pip install pycitizen
Requires Python 3.11+. Only runtime dependency is aiohttp.
Quick start
import asyncio
from pycitizen import CitizenClient
# (west, south, east, north) — e.g. Manhattan
BBOX = (-74.02, 40.70, -73.93, 40.80)
async def main() -> None:
async with CitizenClient() as client:
# 1. Discover every incident marker in the bbox via vector tiles
markers = await client.get_incident_markers(BBOX)
for m in markers:
print(m.incident_id, m.title, m.severity.value, m.timestamp)
# 2. Hydrate full details (batch or single)
incidents = await client.get_incidents([m.incident_id for m in markers])
for inc in incidents:
print(inc.title, inc.location, inc.lifecycle_state.value)
asyncio.run(main())
Tracking a feed
from pycitizen import CitizenClient, IncidentFeed, FeedState
async with CitizenClient() as client:
feed = IncidentFeed(client, BBOX, expire_after=900)
while True:
diff = await feed.update()
for t in diff.added:
print("NEW:", t.marker.title)
for t in diff.updated:
print("UPDATED:", t.marker.title, "->", t.marker.lifecycle_state.value)
for t in diff.removed:
print("GONE:", t.marker.title)
await asyncio.sleep(60)
TrackedIncident retains first_seen, last_seen, and a lifecycle_history of (timestamp, LifecycleState) transitions (reported → verified → developing → resolved/inactive).
API surface
| Method | Endpoint | Notes |
|---|---|---|
health() |
GET /healthz |
reachability check |
get_variable_settings() |
GET /v1/variable_settings_anonymous |
remote config flags |
get_status(lat, lon) |
GET /v1/homescreen/status |
service-area info for a point |
get_service_areas(bbox) |
GET /v1/homescreen/mapExplore |
service-area codes for a bbox |
get_incident_markers(bbox, zoom=12, categories=…, created_gte/lte=…, limit=…, active_definition=…) |
GET /v1/tile/incidents/{x}/{y}/{z}.pbf |
the discovery workhorse; optional filters mirror the app's tile-URL params |
get_historical_incidents(bbox, zoom) |
GET /v1/tile/historical_incidents/{x}/{y}/{z}.pbf |
past-window incidents |
get_offender_markers(bbox, zoom) |
GET /v1/tile/offenders/{x}/{y}/{z}.pbf |
offender registry layer |
get_places(bbox, zoom) |
GET /v1/tile/places/{x}/{y}/{z}.pbf |
OSM place labels |
get_tile_style(name) |
GET /v1/tile/style/{name}.json |
MapLibre style docs |
get_incident(id) |
GET /v3/incident/{id} |
primary detail |
get_incident_v1/v2(id) |
GET /v{1,2}/incident/{id} |
richer/alternate shapes |
get_incidents(ids) |
GET /v1/incidents/batch |
chunked at 50 IDs |
get_related_incidents(id) |
GET /v1/incidents/{id}/related_incidents |
merged/related IDs |
get_incident_content(id) |
GET /v1/incidents/{id}/content |
links/images/streams |
get_incident_map_sources(id) |
GET /v1/incidents/{id}/map_sources |
raw |
get_news_feed(code) |
GET /v2/news/feed |
curated feed |
get_news_briefing(code) |
GET /v2/incidents/news_briefing |
generated briefings |
get_chat_history(id, ...) |
GET /v4/incident_chat/history |
read-only, paginated |
get_location_name(lat, lon) |
GET /v1/safety/location_name |
reverse geocode |
search_locations(q, ...) |
GET /v1/safety/location_search |
place search |
get_location(lat, lon) |
GET /v1/safety/location |
safety location record |
get_public_users(ids) |
GET /v1/users/batch_public |
public profiles |
check_username(name) |
GET /v1/users/check_username |
availability check |
get_social_presence(ids) |
GET /v1/incidents/social/batch |
friend presence (empty w/o auth) |
get_impact_statistics() |
GET /v1/protect/impact_statistics |
Protect marketing stats |
get_neighborhood_details(id) |
GET /v1/trends/neighborhoods/{id}/details |
crime-level summary |
get_neighborhood_incidents(id) |
GET /v1/trends/neighborhoods/{id}/incidents |
typed Incident list |
get_neighborhood_boundary(id) |
GET /v1/trends/neighborhoods/{id}/boundary |
GeoJSON geometry |
get_neighborhood_graph(id) |
GET /v1/trends/neighborhoods/{id}/graph |
category time series |
get_neighborhood_feed(lat, lon) |
GET /v1/trends/shs_feed |
neighborhood for a point |
client.get_json(path, params) is a low-level escape hatch for anything not wrapped.
Examples
Runnable live demos of the whole public surface — see examples/:
python examples/nearby_incidents.py # the full incident pipeline
python examples/feed_watcher.py # IncidentFeed diffing
python examples/map_layers.py # historical / offender / place tiles
python examples/news_and_trends.py # news + neighborhood trends
python examples/misc_public.py # users, usernames, stats, settings
What this SDK does not do
- No authentication flows. Citizen's private endpoints (homescreen feed/mapIncidents, search, friends, variable_settings, user endpoints) return
401without a user token obtained via phone-OTP sign-in. Not implemented. - No WebSocket.
wss://data.sp0n.io/websocketonly carries chat and Protect subscription traffic — not the incident feed — and rejects unauthenticated method calls (auth required). - No push notifications. Alerts arrive via FCM tied to a registered device token; there is no public push channel.
- No mutations. Posting incidents, comments, likes, follows — all auth-gated and out of scope.
Real-time updates = poll the tiles. They are the same source the app's map consumes.
Configuration
from pycitizen import CitizenClient, RateLimiter, RetryPolicy
client = CitizenClient(
rate_limiter=RateLimiter(rate=2.0, burst=2, max_concurrent=2),
retry_policy=RetryPolicy(max_attempts=4),
timeout=15.0,
)
Inject an existing session (e.g. Home Assistant's shared one) with CitizenClient(session=session); close() then becomes a no-op.
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest # fully offline test suite
ruff check src tests # lint
mypy # strict type check (8 modules, zero errors)
python -m build # sdist + wheel
The test suite never touches the network: HTTP is stubbed and the vector-tile path is exercised against a real tile captured from the live API (tests/fixtures/incidents_tile.pbf).
License
MIT — see LICENSE.
Metadata
Release files for pycitizen 0.1.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 | Provenance | Actions |
|---|---|---|---|---|
| pycitizen-0.1.0.tar.gz | 66.3 kB | With provenance | Details for pycitizen-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pycitizen-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pycitizen-0.1.0-py3-none-any.whl |
Total release size: 97.6 kB
Release files / pycitizen-0.1.0.tar.gz
| Download URL | pycitizen-0.1.0.tar.gz |
|---|---|
| Size | 66.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f4fe4ebb1c76b318bcb4f1e5b4a943a0bed57e39599ee46e44b4642c31153d1c
|
|
BLAKE2b-256 checksum How to use checksums |
64f5d0150a6c7767be41df2539b34090262f451a7144141e28a45ae47b9bcaad
|
| 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 20, 2026.
Transparency logRelease files / pycitizen-0.1.0-py3-none-any.whl
| Download URL | pycitizen-0.1.0-py3-none-any.whl |
|---|---|
| Size | 31.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0bb1e3e0f95a2d3cc29e4f1f699340cca0a3e49443d3539091247264b3cf9e27
|
|
BLAKE2b-256 checksum How to use checksums |
84715b4308d29389240bc20b19bf0d780fe55a6adbbc5756f56338ab0ffa7bd5
|
| 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 20, 2026.
Transparency logSecurity for pycitizen 0.1.0
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pycitizen-0.1.0.tar.gz
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 20, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.