Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / territories · raw .md

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 with ls and tar. 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.

arduino
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:

sh
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=):

EndpointReturnsCache
GET /territoriesThe signed channel index (index.json, exact signed bytes)max-age=60
GET /territories/index.sigDetached base64 ed25519 signature over the indexmax-age=60
GET /territories/{id}/{version}/manifestThe version's signed package manifest (exact bytes)max-age=3600
GET /territories/{id}/{version}/manifest.sigDetached manifest signaturemax-age=3600
GET /territories/{id}/{version}/layers/{addr}/{file}A content-addressed layer blob (tar + zstd)immutable, 1 year

Path parameters:

ParameterRequiredDescription
idyesTerritory id, e.g. uk
versionyesPublished package version, e.g. 2.0.0
addryesBlob address: the first 16 lowercase hex characters of the layer's BLAKE3 hash, taken from the signed manifest
fileyesBlob file name, e.g. valhalla.tar.zst

List what is available:

sh
curl -fsS "$BASE/territories" -H "Authorization: Bearer $API_KEY"
json
{
  "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:

StatusMeaning
304Not modified; If-None-Match matched the ETag. Costs no download allowance
402Monthly offline-download allowance exhausted (layer downloads only; see below)
403Provisional key: territory downloads require a verified account
404Unknown territory, version or blob, or no update channel configured on the deployment
416Unsatisfiable 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:

json
{
  "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:

code
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:

kotlin
// app/build.gradle.kts
android {
  buildFeatures { buildConfig = true }
  defaultConfig {
    buildConfigField(
      "String", "MAPMAP_FACTORY_PUBKEY_HEX",
      "\"${providers.gradleProperty("mapmap.factoryPubkeyHex").get()}\"",
    )
  }
}
kotlin
val store = TerritoryStore(
    rootDir = File(context.filesDir, "territories"),
    verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)

iOS: a Swift constant compiled into the app:

swift
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):

kotlin
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):

swift
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:

sh
# 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:

sh
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.jsonl replay 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