Corvus.Instinct

API Reference

The Mozilla Location Service API, self-hosted

Two endpoints under https://instinct.corvusintell.com: POST /v1/geolocate returns a position from the radios a device hears, and POST /v2/geosubmit accepts contributions to the anchor database. Both work without a key, rate-limited per address; a key raises the limits and unlocks the protected extracts.

Conventions

WhatRule
Base addresshttps://instinct.corvusintell.com. HTTPS only, TLS 1.2 or later.
BodyJSON, 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.
KeyOptional 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-AgentSend 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-IdOptional, up to 64 characters of [A-Za-z0-9._-]; echoed in the answer, or generated when absent.
X-Corvus-SnapshotOn every API answer: the anchor snapshot it was computed from.
Unknown fieldsIgnored. A client written for Ichnaea or a newer version of this service works unchanged.
ChangesPaths 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

ScopeUnlocks
geolocatePOST /v1/geolocate and GET /v1/status.
geosubmitPOST /v2/geosubmit with the key's quotas; without a key the endpoint still accepts contributions, counted per IP address.
verboseThe verbose answers below, for internal evaluation tools. Never held by a phone's or a router's key.
zonesProtected zone extracts for the offline mode — built, and not issued in v1.
routerActing 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 }
}

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

FieldRequiredMeaning
macAddressyesThe BSSID: six hex octets, separated by :, - or nothing, any case.
signalStrengthnoRSSI, dBm. Without it the access point still counts as heard, at the model's reach.
agenoMilliseconds since the access point was last heard.
frequencynoChannel centre frequency, MHz. Selects the band's signal model.
channelnoUsed for the band when frequency is missing.
ssidnoRead for the _nomap suffix and for hidden networks, then discarded. Never stored, never logged.
signalToNoiseRatio, radioTypenoAccepted, not used.

Cells, cellTowers

FieldRequiredMeaning
radioTypeyes, here or at the top levelgsm, wcdma, lte or nr.
mobileCountryCode, mobileNetworkCodeyesThe operator's network.
locationAreaCodeGSM; WCDMA with a 16-bit cellIdLAC (GSM, WCDMA) or TAC (LTE, NR).
cellIdyes, except NRCI (GSM, 16 bit), UC-Id (WCDMA, 28 bit), ECI (LTE, 28 bit).
newRadioCellIdNRNCI, 36 bit, for an NR cell. cellId is accepted for NR too.
signalStrengthnoRSSI (GSM), RSCP (WCDMA), RSRP (LTE), SS-RSRP (NR), dBm.
agenoMilliseconds since the cell was last seen.
servingno1 for the serving cell, 0 for a neighbour.
psc, primaryScramblingCodenoPSC (WCDMA) or PCI (LTE, NR). Both of Ichnaea's names are accepted.
timingAdvancenoAs reported: GSM 0–63, LTE 0–1282.
arfcnnoExtension: ARFCN, UARFCN, EARFCN or NR-ARFCN.
asunoAccepted, 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

FieldRequiredMeaning
macAddressunless a beacon identity is givenThe advertiser's address.
signalStrengthnoRSSI, dBm.
agenoMilliseconds since the beacon was last heard.
namenoAccepted and discarded: a BLE local name often carries its owner's name.
addressTypenoExtension: public, random or unknown, as the platform reports it.
txPowernoExtension: calibrated power at 1 m. iBeacon's measured power, or Eddystone's 0 m figure minus 41 dB.
ibeaconnoExtension: {uuid, major, minor}.
eddystoneUidnoExtension: {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

Errors

Errors have Ichnaea's shape: {"error": {"errors": [{"domain", "reason", "message"}], "code", "message"}}.

CodereasondomainWhen
400parseErrorglobalNot JSON, a wrong type, a list over its limit.
400keyInvalidusageLimitsKey missing on an endpoint that needs one (/v1/zones/extract), or sent but unknown, revoked, or without the endpoint's scope.
403userRateLimitExceededusageLimitsThe per-second quota. Carries Retry-After.
403dailyLimitExceededusageLimitsThe daily quota. Carries Retry-After.
404notFoundgeolocationNo position (/v1/geolocate only).
413requestTooLargeglobalBody over its limit.
503serviceUnavailableglobalNo snapshot loaded yet, or load shedding. Carries Retry-After.
500backendErrorglobalA defect.

Limits

ItemLimit
wifiAccessPoints256 per request or item
cellTowers32
bluetoothBeacons100
/v1/geolocate body64 KiB
/v2/geosubmit body2 MiB, at most 1,000 items
Observation age used in a fixage ≤ 30,000 ms

Status & health

EndpointKeyPurpose
GET /healthnoneThe process is alive. Public, for uptime probes.
GET /health/readynone, cluster onlyA snapshot is loaded and its self-test passed.
GET /versionnone, cluster onlyBuild version and commit.
GET /metricsnone, cluster onlyPrometheus metrics.
GET /v1/statusgeolocateThe 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/extractzonesProtected 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.

EndpointKeyPurpose
GET /api/openapi.jsonnoneThe OpenAPI 3.1 document of this API: the three endpoints, their fields, errors and limits.
POST /mcpgeolocateAn 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:

Getting a key

Keys are issued by Corvus, with the scopes and quotas the client needs.

contact@corvusintell.com