LastBar

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:

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.

ScopeFunctionEndpoints
coverage:cellsPullCoverage cellsGET coverage/cells
coverage:areasPullCoverage of one areaGET coverage/areas/{h3_7}
carriers:readPullCarriersGET carriers
pois:readPullPoints of interestGET pois
usage:readPullUsage by dayGET usage
readings:writePushReadingsPOST readings
pois:votesPushVotes on pointsPOST pois/votes
pois:correctionsPushCorrections of pointsPOST pois/corrections
pois:createPushNew pointsPOST 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:

HeaderMeaning
X-RateLimit-LimitThe requests of the tightest window.
X-RateLimit-RemainingWhat is left of it.
X-RateLimit-ResetWhen it starts again, Unix seconds.
Retry-AfterWith 429: seconds to wait.
X-LastBar-Rows-RemainingRows 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.

res 7 ≈ 5 km² res 9 ≈ 0.1 km²
A cell of resolution 7 and some of the 49 cells of resolution 9 in it.

A row is one cell, one carrier and one network class:

FieldMeaning
h3, resThe cell and its resolution.
carrierThe 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.
tech2g, 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.
quality0 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_dbmThe median signal strength in dBm, or null.
voice_ratio, data_ratioThe share of readings registered for calls and for data.
median_kbpsThe median download speed of speed tests, or null.
readings_n, devices_nThe weighted readings behind the cell and the distinct phones. No cell that fewer than two phones measured is given.
lastThe day (UTC) of the newest reading. Never fresher than the app's delay of 24 hours.
altitude_mThe 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:

FieldMeaning
partner_device_idrequiredYour own id of the device, up to 64 characters. LastBar keeps it as a device of your key; it is never shown.
client_reading_idrequiredYour own id of the reading. Sending it again is harmless: it is counted under duplicates.
recorded_atrequiredISO 8601 with a zone. At most 90 days old.
lat, lon, accuracy_mrequiredDegrees (WGS 84) and the accuracy in metres. Readings less accurate than 200 m are kept and left out of the map.
mcc, mncThe carrier of the SIM: 3 digits and 2 or 3.
network_typeAs Android names it: NR, LTE, HSPAP, UMTS, EDGE, GPRS, GSM, NONE.
signal_dbm, rsrp, rsrq, snrStrength in dBm, and LTE/NR measures where you have them.
voice_registered, data_registeredWhether 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_slotReadings of the two SIMs of one phone at one moment share a poll_id.
altitude_m, altitude_accuracy_mBoth or neither.
platformandroid (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" } ] }
ReasonWhat to do
invalid:<field>The field is missing or out of range: fix it and send again.
too_old, in_futureCheck the clock and the zone of recorded_at.
country_not_allowedYour key may push in some countries alone.
devices_limitYour key has as many devices as it may.
daily_limitToday'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:

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" }
StatusCodes
400key_in_url
401unauthenticated, revoked, suspended, expired, partner_suspended, signature_missing, signature_invalid, signature_expired
403ip_not_allowed, scope (the key lacks the scope), not_allowed (outside its limits)
404not_found
422invalid: a parameter or body that cannot be read
429rate_limited, daily_rows: wait Retry-After
503switched_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

Try it in the console

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`.

ParameterInMeaning
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

Try it in the console

GET /coverage/areas/{h3_7}

Every cell of one area

Scope: coverage:areas

ParameterInMeaning
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

Try it in the console

GET /carriers

The carriers your key may see

Scope: carriers:read

200 The carriers

{
    "carriers": [
        {
            "key": "655-10",
            "name": "MTN",
            "country": "ZA"
        }
    ]
}

Try it in the console

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
        }
    ]
}

Try it in the console

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).

ParameterInMeaning
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

Try it in the console

GET /pois

The points of an area or a box

Scope: pois:read

ParameterInMeaning
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"
}

Try it in the console

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

Try it in the console

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

Try it in the console

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

Try it in the console

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
        }
    ]
}

Try it in the console

GET /batches

What you sent, newest first, and what became of it, of the kinds your key may push

Scope: none, any key

ParameterInMeaning
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
        }
    ]
}

Try it in the console

GET /batches/{id}

One batch

Scope: none, any key

ParameterInMeaning
id required path

200 The batch

404 No batch of your key has that id

Try it in the console

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_ and lbp_test_, sent as Authorization: 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:read to pull; readings:write, pois:votes, pois:corrections, pois:create (with GET /pois/mine) to push. A call without its scope is 403 scope. GET /me and GET /batches need none; /batches shows 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 and updated_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-Reset and Retry-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/)".