Documentation menu
Territory packages & OTA updates
Offline navigation runs on territory packages: signed, per-territory
bundles of everything a device needs: routing tiles, base-map tiles and a
geocode index (offline search, including points of interest),
built by our map factory (snfactory) from OSM extracts and ADR restriction
overlays. ADR is the European agreement on carriage of dangerous goods by
road; tunnel codes B–E restrict which tunnels a hazmat load may use; the
full tunnel-code table is on the conventions page.
Trust model
- Every package manifest is signed with ed25519; every layer is content-addressed with BLAKE3 hashes inside the signed manifest.
- Devices never trust the transport. A hostile CDN, mirror or gateway cannot alter what a device accepts. It can deny service, and it can replay an older signed channel state: there is no freshness guarantee by default (rollback-by-republish is a deliberate operator feature, so devices accept any signed version). A stale mirror can therefore hold devices on an older legitimate version; a signed index TTL can be layered on where that matters.
- The update channel is dumb by design: nothing but static files, servable
by any web server or CDN, mirrorable into an air-gapped network with
rsync, inspectable withlsandtar. No server-side logic participates in the trust model.
The public precedents for this shape (signed metadata over content-addressed blobs on an untrusted store) are The Update Framework and OSTree.
Differential over-the-air updates
Layers are content-addressed, so any two package versions that share a layer share a blob. An unchanged multi-gigabyte routing tile tree costs no extra disk on the channel and no extra download on the device. Devices poll the signed channel index on their own schedule, fetch only the layers that changed, verify, and apply with an atomic swap, so an interrupted update never leaves a half-written territory.
snfactory build ──▶ signed package ──▶ snfactory publish ──▶ channel dir
│
(any static host / CDN / rsync mirror)
│
gateway /territories ◄────────────────┘
│
device pulls
│
check_for_update ─▶ apply_update ─▶ verify ─▶ atomic swap
The gateway hop adds authentication, metering and HTTP niceties only; it is deliberately outside the trust boundary. Devices verify every byte themselves, so pointing them at a raw mirror instead changes nothing about what they will accept.
Fetching territories over the API
Status, honestly: the hosted gateway at https://api.mapmap.ai is live:
sign up for a key, or run the self-host distro;
the endpoints below work identically against your own deployment. Base URL,
auth and conventions are defined in full on the
conventions page.
Set up your shell once:
export BASE=https://api.mapmap.ai
export API_KEY=snk_your_key_here
Five endpoints serve the channel, behind the normal API-key auth
(Authorization: Bearer or ?api_key=):
| Endpoint | Returns | Cache |
|---|---|---|
GET /territories | The signed channel index (index.json, exact signed bytes) | max-age=60 |
GET /territories/index.sig | Detached base64 ed25519 signature over the index | max-age=60 |
GET /territories/{id}/{version}/manifest | The version's signed package manifest (exact bytes) | max-age=3600 |
GET /territories/{id}/{version}/manifest.sig | Detached manifest signature | max-age=3600 |
GET /territories/{id}/{version}/layers/{addr}/{file} | A content-addressed layer blob (tar + zstd) | immutable, 1 year |
Path parameters:
| Parameter | Required | Description |
|---|---|---|
id | yes | Territory id, e.g. uk |
version | yes | Published package version, e.g. 2.0.0 |
addr | yes | Blob address: the first 16 lowercase hex characters of the layer's BLAKE3 hash, taken from the signed manifest |
file | yes | Blob file name, e.g. valhalla.tar.zst |
List what is available:
curl -fsS "$BASE/territories" -H "Authorization: Bearer $API_KEY"
{
"format_version": 1,
"generated_at": "2026-07-13T12:00:00Z",
"territories": [
{
"id": "uk",
"display_name": "United Kingdom",
"latest_version": "2.0.0",
"versions": [
{
"version": "2.0.0",
"data_timestamp": "2026-07-01T00:00:00Z",
"manifest_path": "territories/uk/2.0.0/manifest.json",
"total_bytes": 123456789
}
]
}
]
}
total_bytes is the installed size — what the package occupies on the
device. Blobs on the wire are tar + zstd, but how much that saves depends
entirely on the layer: Valhalla tile trees and geocode indexes compress by
roughly 2-3x, while a PMTiles archive is already internally gzipped so zstd
is effectively a no-op on it. The same applies to installedBytes on an
AvailableUpdate. Never present either figure to a user as the cost of the
download, and never compare one against a metered allowance: for a PMTiles-
heavy territory the two are close, and for the rest you will overstate by
2-3x. The index and manifests are served byte-exact so the
detached signatures verify over the response body. Every response carries a
strong BLAKE3 ETag ("b3-<hex>"), and layer blobs support single-range
Range/If-Range requests, so an interrupted multi-gigabyte download
resumes instead of restarting.
Errors:
| Status | Meaning |
|---|---|
304 | Not modified; If-None-Match matched the ETag. Costs no download allowance |
402 | Monthly offline-download allowance exhausted (layer downloads only; see below) |
403 | Provisional key: territory downloads require a verified account |
404 | Unknown territory, version or blob, or no update channel configured on the deployment |
416 | Unsatisfiable Range |
The download-allowance 402
Layer blobs are large (around a gigabyte each), so they are byte-metered
against your account's monthly offline-download allowance: 2 GiB a month by
default on the free tier; a plan or the SDK licence raises it (see
pricing). Metadata (the index, signatures and manifests) is
never metered against it, and neither are 304 or 416 responses. When
serving a blob would exceed the allowance, the gateway refuses before
streaming any bytes:
{
"code": "download_allowance_exceeded",
"allowance_mib": 8192,
"used_mib": 2048,
"message": "offline map download allowance exhausted; upgrade to a plan or the SDK licence for production downloads",
"upgrade": "https://api.mapmap.ai/pricing"
}
This is a different 402 from the x402 payment challenge: the
x402 body is a machine-payable offer (x402Version, accepts[]; pay and
retry), while the download-allowance body above is a monthly cap with an
upgrade pointer, not payable per request. Both are plain application/json
rather than the problem envelope other errors use; the fields distinguish
them. The conventions page covers the error envelope
and the 402-vs-429 split in full.
Coverage
Territory definitions span every continent: country packages across Europe
and North America (the US, Canada, Mexico and Russia among them), plus
continent-scale packages for Africa, Asia, South and Central America and
Australia–Oceania, and more European countries are landing steadily, so
this page deliberately gives no count. The live index at
GET /territories is the source of truth for what a given deployment
actually publishes, including versions and installed sizes.
The hosted channel's verifying key
Every device pins the publishing factory's ed25519 verifying key (the
public half of the signing pair, 64 lowercase hex characters) and rejects
any package or channel index that does not verify against it. For the
hosted channel at api.mapmap.ai, that key is:
c2230696626a1ecbc61cf9ac141eabea5a3a1a15dab70d2acc110bec79108bcf
This is a public key: safe to commit, embed and publish. Bake it into your app at build time; never fetch it at runtime from the same channel it validates.
Android: a Gradle property surfaced as a BuildConfig field:
// app/build.gradle.kts
android {
buildFeatures { buildConfig = true }
defaultConfig {
buildConfigField(
"String", "MAPMAP_FACTORY_PUBKEY_HEX",
"\"${providers.gradleProperty("mapmap.factoryPubkeyHex").get()}\"",
)
}
}
val store = TerritoryStore(
rootDir = File(context.filesDir, "territories"),
verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)
iOS: a Swift constant compiled into the app:
enum MapMapTrust {
/// The hosted channel's factory verifying key (public half).
static let factoryPubkeyHex = "<the 64-hex key above>"
}
let store = TerritoryStore(rootDir: territoriesDir,
verifyingKeyHex: MapMapTrust.factoryPubkeyHex)
Web (no key to pin): the web SDK streams tiles over HTTPS from the gateway and does not install signed packages in the browser; the signed-package trust boundary is the on-device territory store.
Self-host operators pin their own public key
(snfactory keygen writes the pair; embed snfactory.pub's hex), not the
hosted one.
Device flow
The device side is TerritoryStore in the mobile SDKs (a
Kotlin/Swift wrapper over the core's TerritoryManager; see
SDKs and the Android API reference). You
supply a LayerFetcher: a callback
(fetch(relPath, expectedBlake3, destPath), synchronous by contract,
called from a background thread) backed by your platform's HTTP stack
(OkHttp, URLSession) or a plain file copy from a mirror. The
expectedBlake3 argument is advisory: the core re-verifies everything
itself.
Kotlin (ai.mapmap:core):
import ai.mapmap.territory.TerritoryStore
import uniffi.mapmap.LayerFetcher
val store = TerritoryStore(
rootDir = File(context.filesDir, "territories"),
verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX, // pinned at build time
)
// Your transport; the core decides what to fetch and verifies every byte.
val fetcher = object : LayerFetcher {
override fun fetch(relPath: String, expectedBlake3: String, destPath: String) {
download("$BASE/territories/$relPath", File(destPath)) // e.g. OkHttp, synchronous
}
}
// 1. Fetch the signed index: GET /territories and /territories/index.sig.
// 2. Ask the store whether an installed territory has an update.
val update = store.checkForUpdate("uk", indexJson, indexSigBase64, fetcher)
// 3. null means the channel already agrees with the installed version.
// Otherwise it carries the changed layers and their installed size.
if (update != null) {
// update.installedBytes is UNCOMPRESSED — the disk the update adds,
// not the metered bytes on the wire. See the note below.
// applyUpdate is resumable across attempts.
val result = store.applyUpdate(
territoryId = "uk",
newManifestJson = update.manifestJson,
newManifestSigBase64 = update.manifestSig,
fetcher = fetcher,
)
// result.previousVersion → result.territory.version
}
Swift (MapMapKit):
import MapMapKit
let store = TerritoryStore(rootDir: territoriesDir,
verifyingKeyHex: mapMapFactoryPubkeyHex) // pinned at build time
final class URLSessionFetcher: LayerFetcher, @unchecked Sendable {
let baseURL: URL
init(baseURL: URL) { self.baseURL = baseURL }
func fetch(relPath: String, expectedBlake3: String, destPath: String) throws {
// Synchronous by contract (called from a background thread):
let data = try Data(contentsOf: baseURL.appendingPathComponent(relPath))
try data.write(to: URL(fileURLWithPath: destPath), options: .atomic)
}
}
let fetcher = URLSessionFetcher(baseURL: channelURL)
// indexJson / indexSig are the exact served bytes of index.json(.sig):
if let update = try await store.checkForUpdate(
territoryId: "uk", indexJson: indexJson, indexSigBase64: indexSig, fetcher: fetcher
) {
// update.installedBytes is UNCOMPRESSED — see the note below.
// applyUpdate is resumable across attempts.
let result = try await store.applyUpdate(
territoryId: "uk", update: update, fetcher: fetcher)
print("updated \(result.previousVersion) → \(result.territory.version)")
}
checkForUpdate verifies the index signature over the exact bytes,
compares latest_version with the installed version, then fetches and
verifies the remote manifest and plans the differential download. A channel
version older than the installed one is still offered: a rolled-back
channel is an instruction to downgrade. applyUpdate authenticates the
manifest before acting on any of it, stages the new version (unchanged
layers hardlinked from the current install; changed layers fetched, unpacked
and BLAKE3-verified against the signed manifest), runs full package
verification on the staged directory, and atomically swaps it in. A crash,
transport failure, hash mismatch or bad signature at any point leaves the
previous version installed and untouched.
Publishing your own channel (self-host)
Self-hosted deployments sign with their own keys, so you choose who can publish maps to your fleet. The channel directory is plain static files; three commands take a built package live:
# 1. Build and sign the package.
snfactory build --territory uk --workdir /data/uk --signing-key /keys/snfactory.key
# 2. Publish into the channel directory the gateway/CDN serves.
snfactory publish \
--package /data/uk/staging/uk \
--channel-dir /srv/sn-channel \
--signing-key /keys/snfactory.key
# 3. Belt and braces: verify the whole channel before it goes live.
snfactory channel-verify --channel-dir /srv/sn-channel --pubkey /keys/snfactory.pub
Publish is atomic per version (staged then renamed) and idempotent:
republishing the same version regenerates a byte-identical index. Point the
gateway at the directory with SN_CHANNEL_DIR and the /territories
endpoints above serve it. Rollback is republishing the previous index:
version directories are immutable and additive, so remove the bad version,
republish any still-present one, and devices downgrade on their next poll.
For an air-gapped mirror:
rsync -aH --delete /srv/sn-channel/ mirror:/srv/sn-channel/
-H preserves the hardlinks between versions so the mirror does not
balloon. Devices inside the air gap point their LayerFetcher at the
mirror; signatures verify identically because the trust anchor is the
factory public key baked into the app, not the host. Verify a mirror at any
time with the same snfactory channel-verify command.
The private signing key never leaves the factory host; the gateway and CDN hold no secrets. Key rotation ships as an app update (new pinned public key), then a channel republished with the new key.
Deployment properties
- Air-gap capable: mirror the channel directory inside your network; no vendor callback is part of the trust model.
- Auditable: the channel layout is documented and normative; packages can be unpacked and inspected with standard tools.
- Key custody: self-hosted deployments sign with their own keys, so you choose who can publish maps to your fleet.
The full normative specification (channel layout, index determinism, key rotation, threat table) ships with source access, available on request.
Base map coverage
Territory render layers include ocean and land at every zoom, from the factory's global-sources stage (OSM water polygons and Natural Earth), so maps never show empty sea. Styling is a package layer of its own, so a custom style ships and updates independently of the routing and render data.
Hosted routing coverage
The hosted API routes worldwide on a single planet-scale graph
covering every continent, built from a single-day OSM snapshot and
rebuilt monthly with automated regression gates (route-distance sanity
against the great circle, border seam audits, uncovered-landmass
audits). Continent-length journeys, border crossings and ferry legs run
on the same engine and the same key as a city hop. GET /territories
remains the source of truth for which offline packages exist.
Next steps
- File formats: the channel-index JSON schema, the installed-package on-disk layout and the
.drive.jsonlreplay corpus - Self-host: run the gateway and channel on your own infrastructure with your own signing keys
- SDKs: device-side integration on Android, iOS and web
- Conventions: base URL, auth, error envelope, quotas and the ADR tunnel-code table
- API reference: every gateway endpoint