Developers / Partner API 1.0.0
Partner API
Coverage cells to pull, and readings, votes, corrections and points of interest to push. The base
address is https://lastbar.online/api/partner/v1. Try every call in the API console,
or download the OpenAPI 3.1 file.
1. Getting access
The API is for organisations that use coverage where the network stops, or that measure it. Access is free under the partner agreement: what you may do with the data, the attribution, and your promises for what you send. You accept it when you apply for access; once we approve your application, we email you a test key, and live keys follow on request.
You get two kinds of key:
lbp_test_…, the sandbox: answered from a small fixed dataset of made-up carriers of the test network 001 (001-01Sandbox Mobile,001-02Example Telecom ) near Cape Town, South Africa, and every push is checked as a live one would be, then stored nowhere. Its pulls and pushes are held to the key's countries, carriers and areas, as a live key's are. Use it to build and test.lbp_live_…: real data within your key's limits; what you push is kept and reviewed.
A key's secret is shown once when it is made. Keep it on your server, never in an app or a web page that others can read.
2. Authentication
Send the key in the Authorization header of every request:
Authorization: Bearer lbp_live_3f9a1c2b…
A key in the address (?key=, ?api_key=) is refused with 400 key_in_url,
as addresses end up in logs. A key may also be limited to your servers' IP addresses.
What a key may call
Each function of the API has its own scope, and a key may call only the functions it was given: any other
answers 403 scope. Test keys are held to their scopes as live keys are. GET /me
lists your key's scopes; GET /me and GET /batches need none, and
/batches shows only the kinds of batch your key may push.
| Scope | Function | Endpoints | |
|---|---|---|---|
coverage:cells | Pull | Coverage cells | GET coverage/cells |
coverage:areas | Pull | Coverage of one area | GET coverage/areas/{h3_7} |
carriers:read | Pull | Carriers | GET carriers |
pois:read | Pull | Points of interest | GET pois |
usage:read | Pull | Usage by day | GET usage |
readings:write | Push | Readings | POST readings |
pois:votes | Push | Votes on points | POST pois/votes |
pois:corrections | Push | Corrections of points | POST pois/corrections |
pois:create | Push | New points | POST pois, GET pois/mine |
Signing pushes (HMAC)
Where your key requires it, sign each push with your signing secret (lbs_…): the hex HMAC-SHA256
of the Unix time in seconds, a full stop, and the exact body you send. Send both as headers. A signature
more than five minutes old is refused.
X-LastBar-Timestamp: 1762156800
X-LastBar-Signature: hex(HMAC-SHA256(secret, "1762156800." + body))
A worked example, with the secret lbs_0123456789abcdefghijklmnopqrstuvwxyzABCD and the body {"readings":[]}:
message = 1762156800.{"readings":[]}
signature = 277586ae8bdcd802d56069646434541fc45e49d9a6faa1c40cb89228b99aae56
3. Limits
Every key has its own limits, which GET /me gives in full. The defaults are
60 requests a minute, 1,000 an hour and
10,000 in 24 hours (rolling windows), 1,000 rows a request and
100,000 rows a day, 50,000 readings pushed a day and
1,000 points, votes and corrections a day (days in UTC), and up to 500 items a push.
A key may also be limited to countries, carriers, boxes, how far back, the resolution and the fields of a cell.
Every answer says where you stand:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The requests of the tightest window. |
X-RateLimit-Remaining | What is left of it. |
X-RateLimit-Reset | When it starts again, Unix seconds. |
Retry-After | With 429: seconds to wait. |
X-LastBar-Rows-Remaining | Rows your key may still take today. |
429 means a limit is reached: wait Retry-After seconds. 403 not_allowed
means you asked for something outside your key's limits, with the parameter named in field.
The trust score
Where you push readings, LastBar compares them, cell by cell, with what its users' phones measured in the
same places over 90 days. The share of cells where both agree within one quality band is your key's trust
score, in GET /me. A score that holds over enough cells raises your pull limits (up to four
times) and lets an administrator approve your batches without review; a falling score sends them back to review.
4. Pulling coverage
Coverage is given as cells: hexagons of H3, Uber's grid of
the world. Resolution 9 is about 0.1 km² a cell (some 350 m across); resolution 7 about 5 km², each made of
49 cells of resolution 9. Your key's finest resolution is in GET /me.
A row is one cell, one carrier and one network class:
| Field | Meaning |
|---|---|
h3, res | The cell and its resolution. |
carrier | The carrier as MCC-MNC, such as 655-10; any is the best of all carriers there. carrier_name is given once LastBar has reviewed the carrier. |
tech | 2g, 3g, 4g, 5g, none, or any, the best class with signal. A class counts toward every lower one: a 4G reading is also 3G signal. |
quality | 0 no signal, 1 weak, 2 fair, 3 good, 4 excellent: the lower of two marks. By the share of readings with signal: under 25% is 0, under 50% 1, under 80% 2, else 4. By the median strength: below −110 dBm 1, below −100 dBm 2, to −90 dBm 3, above 4 (with no strength, as from iPhones, 3). Readings are weighted by accuracy, age and phone. |
median_dbm | The median signal strength in dBm, or null. |
voice_ratio, data_ratio | The share of readings registered for calls and for data. |
median_kbps | The median download speed of speed tests, or null. |
readings_n, devices_n | The weighted readings behind the cell and the distinct phones. No cell that fewer than two phones measured is given. |
last | The day (UTC) of the newest reading. Never fresher than the app's delay of 24 hours. |
altitude_m | The median altitude of the cell, metres above sea level, rounded to 10. |
At resolution 7, rows of the same area, carrier and class are summed: quality and
median_dbm are averages weighted by readings, cells counts the cells of resolution 9.
Paging and syncing
A page has next_cursor; pass it as cursor for the next, until it is null. For an
incremental sync keep the time you started and ask next time with updated_since: you get only
the cells whose numbers changed. Keep cells by h3, carrier and tech.
Formats
Choose with format=json (the default), geojson or csv, and the fields with fields=.
JSON
{
"res": 9,
"count": 1,
"next_cursor": "WzksIjg5YmM0OTY3MTk3ZmZmZiIsIjY1NS0xMCIsImFueSJd",
"attribution": "Coverage data © LastBar contributors (https://lastbar.online/)",
"cells": [
{ "h3": "89bc496719bffff", "res": 9, "carrier": "655-10", "carrier_name": "MTN", "tech": "any",
"quality": 3, "median_dbm": -97, "voice_ratio": 0.98, "data_ratio": 0.95, "median_kbps": 4200,
"readings_n": 14.5, "devices_n": 3, "last": "2026-10-28", "altitude_m": 120 }
]
}
GeoJSON
A FeatureCollection; each cell a Polygon of its hexagon (longitude, latitude), with the fields as properties.
{
"type": "FeatureCollection",
"next_cursor": null,
"features": [
{ "type": "Feature", "id": "89bc496719bffff/655-10/any",
"geometry": { "type": "Polygon", "coordinates": [[[18.5011, -31.6623], [18.4991, -31.6611], …, [18.5011, -31.6623]]] },
"properties": { "h3": "89bc496719bffff", "res": 9, "carrier": "655-10", "tech": "any", "quality": 3, "median_dbm": -97 } }
]
}
CSV
A header line and a line a row; the next cursor is in the header X-Next-Cursor.
h3,res,carrier,carrier_name,tech,quality,median_dbm,last
89bc496719bffff,9,655-10,MTN,any,3,-97,2026-10-28
5. Pushing readings
POST /readings takes up to 500 readings, each accepted or refused on its own. A reading
is one measurement of one SIM at one place and time:
| Field | Meaning | |
|---|---|---|
partner_device_id | required | Your own id of the device, up to 64 characters. LastBar keeps it as a device of your key; it is never shown. |
client_reading_id | required | Your own id of the reading. Sending it again is harmless: it is counted under duplicates. |
recorded_at | required | ISO 8601 with a zone. At most 90 days old. |
lat, lon, accuracy_m | required | Degrees (WGS 84) and the accuracy in metres. Readings less accurate than 200 m are kept and left out of the map. |
mcc, mnc | The carrier of the SIM: 3 digits and 2 or 3. | |
network_type | As Android names it: NR, LTE, HSPAP, UMTS, EDGE, GPRS, GSM, NONE. | |
signal_dbm, rsrp, rsrq, snr | Strength in dBm, and LTE/NR measures where you have them. | |
voice_registered, data_registered | Whether the SIM was registered for calls and for data. A reading with neither is "no signal", which matters as much as signal. | |
poll_id, sim_slot | Readings of the two SIMs of one phone at one moment share a poll_id. | |
altitude_m, altitude_accuracy_m | Both or neither. | |
platform | android (the default) or ios. |
POST https://lastbar.online/api/partner/v1/readings
{
"readings": [
{ "partner_device_id": "truck-17", "client_reading_id": "r-000001",
"recorded_at": "2026-11-03T08:15:00Z", "lat": -31.66222, "lon": 18.50069, "accuracy_m": 8,
"mcc": "655", "mnc": "10", "network_type": "LTE", "signal_dbm": -97,
"voice_registered": true, "data_registered": true }
]
}
The answer, 202:
{ "batch_id": 412, "status": "pending", "accepted": 499, "duplicates": 0,
"rejected": [ { "id": "r-000077", "reason": "invalid:lat" } ] }
| Reason | What to do |
|---|---|
invalid:<field> | The field is missing or out of range: fix it and send again. |
too_old, in_future | Check the clock and the zone of recorded_at. |
country_not_allowed | Your key may push in some countries alone. |
devices_limit | Your key has as many devices as it may. |
daily_limit | Today's readings are used up; send the rest after midnight UTC. |
Accepted readings then pass the same plausibility filter as the app's: a device that jumps faster than a
vehicle can, a reading at sea or in an aircraft, or at a known test lab, is kept and left out, counted in the
batch as excluded. Your readings count toward the map by your key's weight, which an
administrator sets (0.5 at first; 0 for a trial, when they are kept and counted toward nothing) and which
your trust score may raise.
Points of interest
Read the points of an area with GET /pois (water, shelter, medical, help, fuel and landmarks,
from OpenStreetMap and LastBar's users, ODbL). Push what your people saw on the ground:
POST /pois/votes:there,gone,broken,no_accessormisplaced, observed within 200 m of the point.POST /pois/corrections: what is wrong (phone,name,hours,address,kind,water,other) with the right values inproposed.POST /pois: new points, each with your ownclient_id.GET /pois/minesays what became of them.
Nothing is sent to OpenStreetMap.
Review of what you send
Every push is a batch, which waits for an administrator: approved, it reaches the map or
the points; rejected, it stays out with a reason you are told; or approved in part, by country or device.
Follow it with GET /batches/{id}. Once your key has earned trust, its batches may be approved
without review. Rejected data is deleted after 30 days.
6. Errors
Every error is JSON with a code, a message and, where it helps, the field:
{ "error": "not_allowed", "message": "This key may pull ZA alone.", "field": "country" }
| Status | Codes |
|---|---|
| 400 | key_in_url |
| 401 | unauthenticated, revoked, suspended, expired, partner_suspended, signature_missing, signature_invalid, signature_expired |
| 403 | ip_not_allowed, scope (the key lacks the scope), not_allowed (outside its limits) |
| 404 | not_found |
| 422 | invalid: a parameter or body that cannot be read |
| 429 | rate_limited, daily_rows: wait Retry-After |
| 503 | switched_off: the partner API is paused; try later |
7. Examples
A first pull, with curl
curl -H "Authorization: Bearer $LASTBAR_KEY" \
"https://lastbar.online/api/partner/v1/coverage/cells?country=ZA&carrier=655-10&limit=1000"
An incremental sync, in Python
import os, requests
BASE = "https://lastbar.online/api/partner/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LASTBAR_KEY']}"}
def sync(since=None):
params = {"format": "json", "limit": 1000}
if since:
params["updated_since"] = since
while True:
r = requests.get(f"{BASE}/coverage/cells", headers=HEADERS, params=params)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
r.raise_for_status()
page = r.json()
for cell in page["cells"]:
store(cell) # keep by (h3, carrier, tech)
if not page["next_cursor"]:
break
params["cursor"] = page["next_cursor"]
A signed push, in JavaScript (Node 18 or later)
import { createHmac } from "node:crypto";
const body = JSON.stringify({ readings });
const ts = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", process.env.LASTBAR_SIGNING_SECRET)
.update(`${ts}.${body}`).digest("hex");
const res = await fetch("https://lastbar.online/api/partner/v1/readings", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LASTBAR_KEY}`,
"Content-Type": "application/json",
"X-LastBar-Timestamp": ts,
"X-LastBar-Signature": signature,
},
body,
});
const result = await res.json(); // { batch_id, status, accepted, duplicates, rejected }
Large pushes may be sent gzipped with Content-Encoding: gzip; sign the body as sent, compressed.
8. Attribution and licence
The data is LastBar's, given to you under the partner agreement. Wherever you show it, show the line
"Coverage data © LastBar contributors (https://lastbar.online/)", which every answer also carries in attribution and the
header X-LastBar-Attribution. You may use it in your own products; you may not resell it as a
dataset or rebuild a public coverage map from it. Points of interest are © OpenStreetMap contributors, ODbL.
Reference
Made from the OpenAPI file. Paths are under https://lastbar.online/api/partner/v1.
GET /me
Your key, its scopes and limits, and today's use
Scope: none, any key
200 The key
{
"key": {
"prefix": "lbp_live_3f9a1c2b",
"name": "Production",
"mode": "live",
"expires_at": null
},
"partner": {
"name": "Example Fleet",
"slug": "example-fleet"
},
"scopes": [
"coverage:cells",
"coverage:areas",
"carriers:read",
"usage:read",
"readings:write"
],
"limits": {
"per_minute": 60,
"per_hour": 1000,
"per_day": 10000,
"rows_per_request": 1000,
"rows_per_day": 100000,
"countries": [
"ZA"
],
"carriers": [],
"res9_carriers": [],
"bboxes": [],
"back_days": 548,
"delay_hours": 24,
"finest_res": 9,
"min_devices": 2,
"fields": [
"quality",
"median_dbm"
],
"push_per_day": 50000,
"devices_max": 1000,
"pois_per_day": 1000
},
"trust": {
"score": 0.91,
"cells": 1240,
"pull_multiplier": 4
},
"today": {
"requests": 12,
"rows": 3400,
"rows_remaining": 96600,
"readings_pushed": 500
},
"attribution": "Coverage data © LastBar contributors (https://lastbar.online/)"
}
401 No key, a wrong one, or one revoked, suspended or expired
429 Over a limit; wait Retry-After seconds
GET /coverage/cells
A page of coverage cells
Scope: coverage:cells
The released cells your key may see, per H3 cell, carrier and network class, never fresher than the app's delay, and none that fewer than two phones measured. Filters narrow what your key may see; a filter that reaches outside it is answered 403 `not_allowed` with the field named. Page with `cursor`.
| Parameter | In | Meaning |
|---|---|---|
country |
query | ISO 3166-1 alpha-2 codes, comma-separated. Example: ZA. |
carrier |
query | Carrier keys MCC-MNC, comma-separated; `any` is the best of all carriers. Example: 655-10. |
tech |
query | The network class: a class counts toward every lower one; `any` is the best class with signal, else none. One of none, 2g, 3g, 4g, 5g, any. |
bbox |
query | west,south,east,north in degrees, by the centre of each area of resolution 7. Example: 18.3,-34.2,18.9,-33.7. |
h3_7 |
query | One area of H3 resolution 7. |
updated_since |
query | Only cells whose numbers changed after this time (an incremental sync). Example: 2026-11-01T00:00:00Z. |
res |
query | H3 resolution: 9, about 0.1 km² a cell, or 7, about 5 km², summed. Default: the finest your key may pull. One of 9, 7. |
fields |
query | Fields of a cell to give, comma-separated, among those your key may pull. Example: quality,median_dbm,last. |
format |
query | One of json, geojson, csv. |
limit |
query | Rows of the page, at most rows_per_request. |
cursor |
query | The next_cursor of the page before. |
200 A page
{
"res": 9,
"count": 1,
"next_cursor": "WzksIjg5YmM0OTY3MTk3ZmZmZiIsIjY1NS0xMCIsImFueSJd",
"attribution": "Coverage data © LastBar contributors (https://lastbar.online/)",
"cells": [
{
"h3": "89bc496719bffff",
"res": 9,
"carrier": "655-10",
"carrier_name": "MTN",
"tech": "any",
"quality": 3,
"median_dbm": -97,
"voice_ratio": 0.98,
"data_ratio": 0.95,
"median_kbps": 4200,
"readings_n": 14.5,
"devices_n": 3,
"last": "2026-10-28",
"altitude_m": 120
}
]
}
401 No key, a wrong one, or one revoked, suspended or expired
403 Outside what your key may see, or a scope it lacks
422 A parameter or body that cannot be read
429 Over a limit; wait Retry-After seconds
GET /coverage/areas/{h3_7}
Every cell of one area
Scope: coverage:areas
| Parameter | In | Meaning |
|---|---|---|
h3_7 required |
path | Example: 87bc49671ffffff. |
format |
query | One of json, geojson, csv. |
res |
query | One of 9, 7. |
200 The cells of the area, as /coverage/cells gives them
422 A parameter or body that cannot be read
GET /carriers
The carriers your key may see
Scope: carriers:read
200 The carriers
{
"carriers": [
{
"key": "655-10",
"name": "MTN",
"country": "ZA"
}
]
}
GET /usage
Your key's use by day, the last 30 days
Scope: usage:read
200 Use by day
{
"days": [
{
"day": "2026-11-03",
"requests": 120,
"rows": 54000,
"readings_pushed": 2500,
"readings_accepted": 2470,
"throttled": 0,
"forbidden": 1,
"errors": 2
}
]
}
POST /readings
Push readings
Scope: readings:write
Up to 500 readings, each accepted or rejected on its own. Accepted readings are kept as a batch that waits for review; poll /batches/{id}. Sign the body where your key requires it (X-LastBar-Timestamp, X-LastBar-Signature).
| Parameter | In | Meaning |
|---|---|---|
X-LastBar-Timestamp |
header | Unix seconds, for a signed push. |
X-LastBar-Signature |
header | Hex HMAC-SHA256 of "{timestamp}.{body}" with your signing secret. |
Body:
{
"readings": [
{
"partner_device_id": "truck-17",
"client_reading_id": "r-000001",
"recorded_at": "2026-11-03T08:15:00Z",
"lat": -31.66222,
"lon": 18.50069,
"accuracy_m": 8,
"mcc": "655",
"mnc": "10",
"network_type": "LTE",
"signal_dbm": -97,
"voice_registered": true,
"data_registered": true
}
]
}
202 Taken as a batch (or, for a test key, checked and stored nowhere)
{
"batch_id": 412,
"status": "pending",
"accepted": 499,
"duplicates": 0,
"rejected": [
{
"id": "r-000077",
"reason": "invalid:lat"
}
]
}
401 No key, a wrong one, or one revoked, suspended or expired
422 A parameter or body that cannot be read
GET /pois
The points of an area or a box
Scope: pois:read
| Parameter | In | Meaning |
|---|---|---|
h3_7 |
query | |
bbox |
query | west,south,east,north, at most one square degree. |
200 The points, with their votes as they stand
{
"points": [
{
"id": "osm:n123",
"kind": "spring",
"group": "water",
"name": "Bron",
"lat": -31.66222,
"lon": 18.50069,
"still_there": 3,
"not_there": 0
}
],
"attribution": "© OpenStreetMap contributors (ODbL) and LastBar contributors"
}
POST /pois
Push new points
Scope: pois:create
Body:
{
"points": [
{
"client_id": "p-1",
"partner_device_id": "ranger-3",
"kind": "spring",
"name": "Bron",
"description": "Below the old dam wall",
"drinkable": true,
"lat": -31.66222,
"lon": 18.50069,
"accuracy_m": 6,
"observed_at": "2026-11-03T08:15:00Z"
}
]
}
202 Taken as a batch
POST /pois/votes
Vote whether points are still there
Scope: pois:votes
Body:
{
"votes": [
{
"poi": "osm:n123",
"vote": "broken",
"partner_device_id": "ranger-3",
"observed_at": "2026-11-03T08:15:00Z",
"lat": -31.66231,
"lon": 18.50072
}
]
}
202 Taken as a batch
POST /pois/corrections
Say what is wrong with points, with the right values
Scope: pois:corrections
Body:
{
"corrections": [
{
"poi": "osm:n123",
"reason": "phone",
"note": "New number on the gate",
"proposed": {
"phone": "+27 21 555 0101"
},
"partner_device_id": "ranger-3",
"observed_at": "2026-11-03T08:15:00Z",
"lat": -31.66231,
"lon": 18.50072
}
]
}
202 Taken as a batch
GET /pois/mine
What became of your points
Scope: pois:create
200 Your points, newest first
{
"points": [
{
"id": "user:4120",
"batch_id": 415,
"status": "approved",
"reason": null,
"kind": "spring",
"lat": -31.66222,
"lon": 18.50069
}
]
}
GET /batches
What you sent, newest first, and what became of it, of the kinds your key may push
Scope: none, any key
| Parameter | In | Meaning |
|---|---|---|
kind |
query | One of readings, pois, votes, corrections. |
status |
query | One of pending, approved, rejected, partly_approved. |
200 Batches
{
"batches": [
{
"id": 412,
"kind": "readings",
"status": "pending",
"received": 500,
"accepted": 499,
"rejected": 1,
"excluded": 3,
"approved": 0,
"rejections": {
"invalid:lat": 1
},
"reason": null,
"created_at": "2026-11-03T08:20:00Z",
"reviewed_at": null
}
]
}
GET /batches/{id}
One batch
Scope: none, any key
| Parameter | In | Meaning |
|---|---|---|
id required |
path |
200 The batch
404 No batch of your key has that id
9. Changelog and versioning
/v1 stays backwards compatible: fields and endpoints may be added, never removed or changed in
meaning without notice. A breaking change is /v2, run beside /v1; anything marked
deprecated is kept at least six months. Changes to what the data means (how quality is worked out, the delay,
the two-phone minimum) are announced here and by email to every partner with a key.
Every change to the partner API under /api/partner/v1, newest first. A change that could break a partner's code gets a new major version and a new prefix (/api/partner/v2), with v1 kept for at least six months after it is announced here and by email.
1.0.0 - 3 November 2026
The first version.
- Keys:
lbp_live_andlbp_test_, sent asAuthorization: Bearer, never in the address. Optional IP allowlist and HMAC signing of pushes. - One scope per function, given to each key one by one:
coverage:cells,coverage:areas,carriers:read,pois:read,usage:readto pull;readings:write,pois:votes,pois:corrections,pois:create(withGET /pois/mine) to push. A call without its scope is403 scope.GET /meandGET /batchesneed none;/batchesshows only the kinds the key may push. GET /me,GET /usage: the key, its scopes, limits, trust score and today's use; use by day.GET /coverage/cells,GET /coverage/areas/{h3_7},GET /carriers: released coverage cells at H3 resolution 9 or 7, in JSON, GeoJSON or CSV, with filters by country, carrier, network class, box, area andupdated_since, paged with a cursor. No cell that fewer than two phones measured, none fresher than the app's delay.POST /readings: up to 500 readings a request, each accepted or rejected on its own, kept as a batch for review.GET /pois,POST /pois,POST /pois/votes,POST /pois/corrections,GET /pois/mine: points of interest to read, new points of water, shelter and the rest, votes and corrections, each kept as a batch for review.GET /batches,GET /batches/{id}: what became of what you sent.- Rate limits with
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-ResetandRetry-After. - The sandbox: a test key is answered from a fixed dataset of carriers of the test network 001, and its pushes are checked and stored nowhere.
- Access: apply at
/developers/apply, which accepts the partner agreement, version 1.0 (/developers/agreement); an approved application gets a test key by email, and live keys follow on request. - The attribution to show wherever coverage data is shown: "Coverage data © LastBar contributors (https://lastbar.online/)".