Conventions
| What | Rule |
|---|---|
| Base address | https://instinct.corvusintell.com. HTTPS only, TLS 1.2 or later. |
| Body | JSON, UTF-8. The Content-Type header is not checked — microG's older backend sends JSON as a form. A body may be gzip-compressed (Content-Encoding: gzip), as NeoStumbler sends every upload; limits count the decompressed body. |
| Key | Optional on /v1/geolocate, /v2/geosubmit, /v1/status and /mcp: without one the request is served per IP address, with a lower quota. Required on /v1/zones/extract. Send it in the header X-Api-Key: <key> (recommended — it stays out of URLs and their logs) or ?key=<key>. |
User-Agent | Send your client's own. The browser check in front of the service refuses the default of some HTTP libraries (Python's urllib) with 403. |
X-Request-Id | Optional, up to 64 characters of [A-Za-z0-9._-]; echoed in the answer, or generated when absent. |
X-Corvus-Snapshot | On every API answer: the anchor snapshot it was computed from. |
| Unknown fields | Ignored. A client written for Ichnaea or a newer version of this service works unchanged. |
| Changes | Paths are Ichnaea's. Only additive changes are made within them. |
Authentication & quotas
No key is needed to position or to contribute: the service counts such requests per client IP address. A key is a credential of Corvus's own — issued by Corvus to its clients and testers, each with its scopes and higher quotas — and it is the only way into the protected zone extracts. A key that is sent is always checked: an unknown, revoked or wrong-scope key answers 400 keyInvalid and is never served as an anonymous request.
Scopes
| Scope | Unlocks |
|---|---|
geolocate | POST /v1/geolocate and GET /v1/status. |
geosubmit | POST /v2/geosubmit with the key's quotas; without a key the endpoint still accepts contributions, counted per IP address. |
verbose | The verbose answers below, for internal evaluation tools. Never held by a phone's or a router's key. |
zones | Protected zone extracts for the offline mode — built, and not issued in v1. |
router | Acting for many phones behind one key: the X-Client-Id header then names the phone, and quotas count per phone. |
Quotas
With a key, unless it says otherwise: 2 requests per second (a burst of 5), 3,000 geolocate requests a day, 20,000 contributed items a day. Without a key, per IP address: 1 request per second (a burst of 3), 2,000 geolocate requests a day, 10,000 contributed items a day. Over a quota the service answers 403 with Retry-After.
POST /v1/geolocate
Send the radios the device hears; receive one position with a calibrated radius. A fix needs at least two known radios that agree.
Request
POST /v1/geolocate Content-Type: application/json X-Api-Key: <key> { "considerIp": false, "wifiAccessPoints": [ { "macAddress": "b0:95:75:b0:1c:b9", "signalStrength": -60, "frequency": 2412, "age": 1200, "ssid": "TP-Link_1CB9" }, { "macAddress": "50:ff:20:11:68:9a", "signalStrength": -74, "frequency": 5180, "age": 300 }, { "macAddress": "ac:84:c6:d3:a5:cb", "signalStrength": -81, "channel": 6 } ], "cellTowers": [ { "radioType": "lte", "mobileCountryCode": 255, "mobileNetworkCode": 6, "locationAreaCode": 21051, "cellId": 71234567, "signalStrength": -95, "psc": 214, "timingAdvance": 7, "serving": 1 }, { "radioType": "nr", "mobileCountryCode": 255, "mobileNetworkCode": 3, "newRadioCellId": 68719476 } ], "bluetoothBeacons": [ { "macAddress": "f4:12:fa:3b:01:9c", "signalStrength": -71, "addressType": "public", "txPower": -59 }, { "macAddress": "6c:2a:91:07:5e:11", "signalStrength": -80, "ibeacon": { "uuid": "f7826da6-4fa2-4e98-8024-bc5b71e0893e", "major": 10, "minor": 4 } } ] }
Answer — a fix
HTTP/1.1 200 OK
X-Corvus-Snapshot: 2026-10-18.1
{
"location": { "lat": 50.4497236, "lng": 30.5232617 },
"accuracy": 29.0,
"corvus": { "method": "access_points", "accuracy68": 18.0 }
}
locationandaccuracyare Ichnaea's: the position, and the radius in metres that holds the true position with 95 % confidence.corvusis this service's extension:methodisaccess_pointswhen Wi-Fi or BLE anchors carried the fix, andcellswhen cells alone did. A fix is never blended.accuracy68is the 68 % radius, the convention of Android'sLocation.getAccuracy().
fallbackis never present. The service has no IP or location-area fallback;considerIpandfallbacksare accepted and ignored.
Answer — no fix
HTTP/1.1 404 Not Found
X-Corvus-Snapshot: 2026-10-18.1
{
"error": {
"errors": [ { "domain": "geolocation", "reason": "notFound", "message": "Not found" } ],
"code": 404,
"message": "Not found"
}
}
Every miss gets this same answer, whatever its cause: fewer than two observations, observations the database does not know, or known ones that disagree. A client that wants to say "not enough access points" knows how many it sent.
Verbose answers
?verbose=1, from a key with the verbose scope, adds to corvus a detail (ok, too_few_observations, too_few_known, inconsistent, cell_disagrees), one results entry per technology that could stand on its own, and outcomes per observation (used, unknown, outlier, inconsistent, privacy_gate, filtered:<rule>). The scope belongs to internal evaluation tools; it is not issued to clients.
Observation fields
Wi-Fi, wifiAccessPoints
| Field | Required | Meaning |
|---|---|---|
macAddress | yes | The BSSID: six hex octets, separated by :, - or nothing, any case. |
signalStrength | no | RSSI, dBm. Without it the access point still counts as heard, at the model's reach. |
age | no | Milliseconds since the access point was last heard. |
frequency | no | Channel centre frequency, MHz. Selects the band's signal model. |
channel | no | Used for the band when frequency is missing. |
ssid | no | Read for the _nomap suffix and for hidden networks, then discarded. Never stored, never logged. |
signalToNoiseRatio, radioType | no | Accepted, not used. |
Cells, cellTowers
| Field | Required | Meaning |
|---|---|---|
radioType | yes, here or at the top level | gsm, wcdma, lte or nr. |
mobileCountryCode, mobileNetworkCode | yes | The operator's network. |
locationAreaCode | GSM; WCDMA with a 16-bit cellId | LAC (GSM, WCDMA) or TAC (LTE, NR). |
cellId | yes, except NR | CI (GSM, 16 bit), UC-Id (WCDMA, 28 bit), ECI (LTE, 28 bit). |
newRadioCellId | NR | NCI, 36 bit, for an NR cell. cellId is accepted for NR too. |
signalStrength | no | RSSI (GSM), RSCP (WCDMA), RSRP (LTE), SS-RSRP (NR), dBm. |
age | no | Milliseconds since the cell was last seen. |
serving | no | 1 for the serving cell, 0 for a neighbour. |
psc, primaryScramblingCode | no | PSC (WCDMA) or PCI (LTE, NR). Both of Ichnaea's names are accepted. |
timingAdvance | no | As reported: GSM 0–63, LTE 0–1282. |
arfcn | no | Extension: ARFCN, UARFCN, EARFCN or NR-ARFCN. |
asu | no | Accepted, not used. |
Android reports a missing value as 2147483647 (CellInfo.UNAVAILABLE), or 9223372036854775807 for a 64-bit field. Either is treated as absent; so is an area code of 0, 65535 or below zero.
BLE, bluetoothBeacons
| Field | Required | Meaning |
|---|---|---|
macAddress | unless a beacon identity is given | The advertiser's address. |
signalStrength | no | RSSI, dBm. |
age | no | Milliseconds since the beacon was last heard. |
name | no | Accepted and discarded: a BLE local name often carries its owner's name. |
addressType | no | Extension: public, random or unknown, as the platform reports it. |
txPower | no | Extension: calibrated power at 1 m. iBeacon's measured power, or Eddystone's 0 m figure minus 41 dB. |
ibeacon | no | Extension: {uuid, major, minor}. |
eddystoneUid | no | Extension: {namespace, instance}. |
POST /v2/geosubmit
Contributions arrive here, apart from positioning: a scan with a trusted position, from a client whose user agreed to contribute. The answer is 200 with {}, whatever was kept — what fails a rule is dropped and counted, never refused.
POST /v2/geosubmit Content-Type: application/json X-Api-Key: <key> { "items": [ { "timestamp": 1792330353481, "position": { "latitude": 50.4497236, "longitude": 30.5232617, "accuracy": 11.0, "age": 480, "source": "gps" }, "wifiAccessPoints": [ { "macAddress": "b0:95:75:b0:1c:b9", "signalStrength": -60, "frequency": 2412, "age": 1200 } ], "cellTowers": [], "bluetoothBeacons": [] } ] }
What an item needs to count
- No key needed. A key with the
geosubmitscope raises the quota; without one the submission is counted per IP address. The same rules below apply either way. - Consent is the client's to collect. A client submits only what its user agreed to contribute.
- A trusted position. An item counts only with
position.sourcegps(the default), notmanualorfused— a fused position may itself come from Wi-Fi positioning.position.accuracymust be 15 m or better. - A recent timestamp. Milliseconds since the UNIX epoch; absent means now. An item counts only within the last year and no more than a day ahead.
- An observation near its position. Within 10 s of the position fix, measured as the difference of the two
agevalues; within 15 m of travel whenspeedis given. - Hidden and
_nomapnetworks are dropped, as Ichnaea's rules require of collectors. A network whosessidis present and empty is hidden. - At least one cell or beacon, or two access points, as in Ichnaea.
- Not in a sensitive area. An item whose position lies in a map tile that touches a military object is dropped.
Errors
Errors have Ichnaea's shape: {"error": {"errors": [{"domain", "reason", "message"}], "code", "message"}}.
| Code | reason | domain | When |
|---|---|---|---|
| 400 | parseError | global | Not JSON, a wrong type, a list over its limit. |
| 400 | keyInvalid | usageLimits | Key missing on an endpoint that needs one (/v1/zones/extract), or sent but unknown, revoked, or without the endpoint's scope. |
| 403 | userRateLimitExceeded | usageLimits | The per-second quota. Carries Retry-After. |
| 403 | dailyLimitExceeded | usageLimits | The daily quota. Carries Retry-After. |
| 404 | notFound | geolocation | No position (/v1/geolocate only). |
| 413 | requestTooLarge | global | Body over its limit. |
| 503 | serviceUnavailable | global | No snapshot loaded yet, or load shedding. Carries Retry-After. |
| 500 | backendError | global | A defect. |
Limits
| Item | Limit |
|---|---|
wifiAccessPoints | 256 per request or item |
cellTowers | 32 |
bluetoothBeacons | 100 |
/v1/geolocate body | 64 KiB |
/v2/geosubmit body | 2 MiB, at most 1,000 items |
| Observation age used in a fix | age ≤ 30,000 ms |
Status & health
| Endpoint | Key | Purpose |
|---|---|---|
GET /health | none | The process is alive. Public, for uptime probes. |
GET /health/ready | none, cluster only | A snapshot is loaded and its self-test passed. |
GET /version | none, cluster only | Build version and commit. |
GET /metrics | none, cluster only | Prometheus metrics. |
GET /v1/status | geolocate | The served snapshot's version and age, and anchor counts per technology. No positions. Without a key: the same snapshot block at the per-address rate, and no build line (the commit stays cluster-side, as on /version). |
POST /v1/zones/extract | zones | Protected zone extracts for the offline mode — built, and not issued in v1. |
Ichnaea's deprecated /v1/search, /v1/submit and /v1/geosubmit are not offered, nor is /v1/region. No endpoint lists anchors, returns one anchor's position, or exports the database: a position, never a download.
OpenAPI & MCP
The same API, in two forms a machine reads: an OpenAPI document for code generators and HTTP clients, and an MCP server for AI agents. The document is public, like this page; the tool is the API above — same key, same quotas, same answers.
| Endpoint | Key | Purpose |
|---|---|---|
GET /api/openapi.json | none | The OpenAPI 3.1 document of this API: the three endpoints, their fields, errors and limits. |
POST /mcp | geolocate | An MCP server (Model Context Protocol, streamable HTTP). One tool, geolocate: the body of POST /v1/geolocate, its answer. |
OpenAPI
GET /api/openapi.json answers the OpenAPI 3.1 description of /v1/geolocate, /v2/geosubmit and /v1/status, with the X-Api-Key scheme and an example request. It is public, like this page — it describes the API, it does not answer it.
MCP
POST /mcp speaks JSON-RPC 2.0, one message per POST, no session, no batches. A key, when sent, rides in X-Api-Key on every call, handshake included, as on every endpoint of this API; without one the same path is served per IP address. The handshake is initialize (protocol revisions 2024-11-05, 2025-03-26 and 2025-06-18 are spoken), then tools/list and tools/call. The one tool, geolocate, takes the same JSON as POST /v1/geolocate and answers the same fix or the same 404 notFound, as one text block. A keyed call's handshakes do not count against the quota; without a key every message takes its address's rate token at the headers, handshakes included.
$ curl -X POST https://instinct.corvusintell.com/mcp \ > -H "Content-Type: application/json" \ > -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", > "params":{"name":"geolocate","arguments":{ > "wifiAccessPoints":[ > {"macAddress":"b0:95:75:b0:1c:b9","signalStrength":-60}, > {"macAddress":"50:ff:20:11:68:9a","signalStrength":-74} > ]}}}' HTTP/1.1 200 OK { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"location\": {\"lat\": 50.4497236, \"lng\": 30.5232617}, …}" } ], "isError": false } }
A miss is not a tool error: the tool did its work and the answer is the service's — the text carries the same notFound body the REST answer would. A key without the geolocate scope, or over a quota, is refused as on REST — the same 400 and 403, before any body is read.
Client compatibility
The API is the Mozilla Location Service's, so a client written for MLS or beaconDB needs only this address and a key. Positioning works from GeoClue, microG and Firefox-era MLS clients; submissions work from NeoStumbler, Tower Collector and Network Survey. Two practical notes:
- Send your own
User-Agent. The browser check in front of the service answers the default of some HTTP libraries (Python'surllib) with403before the request reaches the service. - Gzip is welcome. A body sent with
Content-Encoding: gzipis decompressed server-side; the limits count the decompressed bytes.
Getting a key
Keys are issued by Corvus, with the scopes and quotas the client needs.
contact@corvusintell.com