Connect your app to SAR Map Canada
Any app or service (a drone app like Eagle Eyes, a detection tool like Mirada, a dispatch or CAD system, a tracker gateway) can work with team maps both ways:
- Read maps and their markers, lines, areas and folders as GeoJSON.
- Write: add, change and delete features; they appear on everyone's map within a second.
- Hear about changes: a live stream for apps that stay connected, or webhooks for servers.
- Send live positions for drones, phones and trackers.
There's one process for every integration. Nobody at the SAR team has to copy keys or edit settings:
- Your app asks us for a pairing code and shows it to the user.
- A team member opens
/connect, types the code, and chooses which folder your app may use and what it may do (send positions, view, or view and edit). - Your app collects its connection token and uses it as a Bearer token from then on.
- The team sees your app under Connected apps and can disconnect it at any time.
All endpoints are JSON over HTTPS and described in the
OpenAPI document. Examples below use https://map.asar.team;
a runnable example client is in the repository at examples/integration-example.mjs.
1. Connect: pairing code
This is the OAuth 2.0 device authorization pattern (RFC 8628), the same one TVs use for "enter this code on your phone". It suits drone controllers, field laptops and servers: no browser or redirect URL is needed inside your app.
Start
POST /api/connect/start
Content-Type: application/json
{ "app": "Eagle Eyes", "device": "M30T RC Plus" }
→ 200
{ "deviceCode": "…secret, keep it in your app…",
"userCode": "7B7M-KKVM",
"verificationUrl": "https://map.asar.team/connect",
"verificationUrlComplete": "https://map.asar.team/connect?code=7B7M-KKVM",
"expiresIn": 600, "interval": 5 }
Show the user the userCode and the verificationUrl. A QR code of
verificationUrlComplete is the friendliest option on a device with a screen.
Wait for approval
Poll every interval seconds (never faster) until you get a token or a final error:
POST /api/connect/poll
{ "deviceCode": "…" }
→ 400 { "error": "authorization_pending" } keep waiting
→ 400 { "error": "slow_down" } you polled too fast: wait longer
→ 400 { "error": "access_denied" } the user said no
→ 400 { "error": "expired_token" } took more than 10 minutes: start again
→ 200 { "token": "sa.…", "org": { "id", "name" }, "folder": { "id", "name" } | null,
"permission": "track" | "view" | "edit", "apiBase": "https://map.asar.team" }
The token is shown once. Store it securely (Android Keystore, iOS Keychain, an OS secret store). Send it on every request:
Authorization: Bearer sa.…
Scripts and servers
Tools that can't show a code can use an API key instead: a team admin creates one under My maps → Connected apps → Create an API key. It works exactly like a pairing token.
2. Read maps
GET /api/v1/maps
→ { "maps": [ { "id": "SQCWJAM", "title": "Task 26-1187", "orgName": "Arrowsmith SAR",
"folderId": "…", "access": "write", "updatedAt": "…" } ] }
GET /api/v1/maps/SQCWJAM/features
→ { "type": "FeatureCollection", "head": 42, "features": [ … GeoJSON … ] }
Your app only ever sees maps in the folder it was connected to (and folders inside it).
Staying up to date cheaply
Every change has a sequence number. Remember head, then ask only for what changed:
GET /api/v1/maps/SQCWJAM/features?since=42
→ { "type": "FeatureCollection", "head": 45, "features": [ …changed… ], "deleted": [ "…ids…" ] }
3. Write features
POST /api/v1/maps/SQCWJAM/features
Idempotency-Key: 3f2c1e9a-… (recommended: any unique string per change)
{ "title": "Possible PLS", "geometry": { "type": "Point", "coordinates": [-124.61, 49.23] },
"properties": { "marker-color": "#c1121f", "description": "Thermal hit, 02:14" } }
→ 201 { "type": "Feature", "id": "…", … }
PATCH /api/v1/maps/SQCWJAM/features/{id} { "properties": { "description": "Confirmed: backpack" } }
DELETE /api/v1/maps/SQCWJAM/features/{id} → 204
Always send an Idempotency-Key and reuse it when you retry after a
dropped connection: the change is applied once, never twice. Field links are unreliable; queue
changes while offline and send them in order when you're back.
properties are merged on PATCH; set a key to null to remove it. Keys
you invent (prefix them, e.g. eagle_eyes:detection_id) are stored and returned untouched.
The reserved keys class, title, folderId, updatedAt,
updatedBy and seq are set by the server.
You may choose the feature id yourself (a UUID) on POST, which helps when your app
tracks its own objects; posting an id that already exists returns 409 feature_exists.
Geometry: Point, LineString or Polygon (closed rings), [lon, lat] or
[lon, lat, alt], up to 10,000 positions.
4. Live updates (apps that stay connected)
A Server-Sent Events stream: one long HTTP GET. Any language can read it; browsers have
EventSource.
GET /api/v1/maps/SQCWJAM/live?since=45
Authorization: Bearer sa.…
id: 46
event: ops
data: { "head": 46, "ops": [ { "seq": 46, "featureId": "…", "kind": "update",
"actor": "…", "at": "…", "after": { …the feature… } } ] }
event: track
data: { "connectKeyId": "…", "label": "M30T-1", "points": [ { "lng", "lat", "alt", "at" } ] }
event: revoked your connection was removed: stop and tell the user
: ping every 15 s
If the stream drops, reconnect with Last-Event-ID (or ?since=) set to the
last id you saw: you get everything you missed, nothing twice.
5. Live positions
POST /api/v1/maps/SQCWJAM/positions
{ "device": "M30T-1",
"points": [ { "lat": 49.2301, "lng": -124.6121, "alt": 1150, "at": "2026-10-30T02:14:05Z",
"speed": 7.5, "heading": 280 } ],
"props": { "eagle_eyes:public_drone_id": "…" } }
→ { "accepted": 1, "duplicates": 0 }
Send at as the time the position was measured, not when you send it: points queued
offline land in the right place on the track. Re-sending the same point is harmless. Works with
any permission, including send positions only.
6. Webhooks (servers)
If your service can't hold a stream open, register a URL and we'll POST every change to it.
POST /api/v1/webhooks
{ "url": "https://your.server/sar-map-hook", "mapId": "SQCWJAM" } (omit mapId for every map you can see)
→ 201 { "id": "…", "secret": "…shown once…" }
Each delivery:
POST https://your.server/sar-map-hook
X-SARMap-Event: features.changed | positions.received
X-SARMap-Delivery: …unique id…
X-SARMap-Timestamp: …
X-SARMap-Signature: sha256=…hex HMAC-SHA256 of the raw body with your secret…
{ "id": "…", "type": "features.changed", "mapId": "SQCWJAM", "head": 47, "ops": [ … ], "at": "…" }
Verify the signature before trusting a delivery: it's an HMAC of the raw body
only. Reply 2xx quickly. Changes made within a second are batched into one delivery, with at most
1000 ops; if head is past the last op you received, fetch /features?since=.
Failed deliveries are retried after 5 s, 30 s, 2 min and 10 min (same delivery id, so you can
de-duplicate); after 20 failures in a row the webhook is switched off. Redirects aren't followed,
and production webhooks must use https on a public address.
// Node
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-sarmap-signature']));
# Python
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers['X-SARMap-Signature'])
// Kotlin
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret.toByteArray(), "HmacSHA256")) }
val expected = "sha256=" + mac.doFinal(rawBody).joinToString("") { "%02x".format(it) }
val ok = MessageDigest.isEqual(expected.toByteArray(), signatureHeader.toByteArray())
7. Photos
Attach photos to any marker, line or area, or create a marker straight from a photo. Send the image bytes as the request body: no multipart forms. JPEG, PNG or WebP, up to 25 MB. Photos keep their EXIF: the time taken and GPS position are read from it.
POST /api/v1/maps/SQCWJAM/features/{featureId}/photos?caption=Detection%201
Content-Type: image/jpeg
Idempotency-Key: … (retry-safe, like other writes)
<the JPEG bytes>
→ 201 { "id": "…", "featureId": "…", "width": 4000, "height": 3000, "takenAt": "…",
"location": { "lat", "lng", "alt" } | null, "caption": "Detection 1",
"url": "/api/v1/media/…", "mediumUrl": "…/medium", "thumbUrl": "…/thumb" }
POST /api/v1/maps/SQCWJAM/photos?title=Possible%20PLS a new marker at the photo's GPS
POST /api/v1/maps/SQCWJAM/photos?lat=49.23&lng=-124.61 …or at a position you give
→ 201 { "feature": { …GeoJSON… }, "photo": { … } }
→ 400 { "error": "no_location" } the photo has no GPS and you gave no lat/lng
The feature's properties.photos lists its photos, with url,
mediumUrl (up to 1600 px) and thumbUrl (up to 320 px) to fetch them
using your token. Adding, captioning or deleting a photo is an ordinary change, so it also arrives
on the live stream and in webhooks.
GET /api/v1/media/{id} original /medium /thumb /info (JSON)
PATCH /api/v1/media/{id} { "caption": "…" }
DELETE /api/v1/media/{id} → 204
Uploading needs view and edit. Send positions only connections can't add photos.
8. Map overlays (mapsheets)
Lay a georeferenced image on the map: a scanned or exported mapsheet, an orthophoto, a drawing. Send the file bytes as the request body (no multipart): a GeoTIFF, a KMZ with a GroundOverlay (Google Earth, Avenza, CalTopo), a GeoPDF, or a PNG, JPEG or WebP. Up to 250 MB. The type is read from the file itself.
POST /api/v1/maps/SQCWJAM/overlays?title=92F%2F07
Content-Type: image/tiff
Idempotency-Key: …
<the GeoTIFF bytes>
→ 201 { "feature": { "properties": { "class": "Overlay", "overlay": { … } }, "geometry": { "type": "Polygon", … } },
"overlay": { "mediaId": "…", "corners": [[lng,lat] TL, TR, BR, BL], "opacity": 0.75,
"width": 8192, "height": 6144, "georef": "geotiff", "crs": "EPSG:3005" } }
Where it goes, in order: the GeoTIFF's own tags; the KMZ LatLonBox (rotation
included) or gx:LatLonQuad; a world file you pass as
world=A,D,B,E,C,F (the six lines of the .pgw/.jgw/.tfw) with epsg=;
otherwise it is centred in place=w,s,e,n (your current view) for the user to align
by hand ("georef": "manual").
POST /api/v1/maps/SQCWJAM/overlays?world=2.5,0,0,-2.5,412001.25,5456998.75&epsg=26910 PNG + its .pgw
POST /api/v1/maps/SQCWJAM/overlays?place=-124.9,49.1,-124.3,49.4 no georeference
→ 400 { "error": "no_location" } no georeference and no place
→ 400 { "error": "crs_required" } a world file in metres without epsg
→ 201 { …, "warning": "unsupported_crs", "crs": "EPSG:2056" } placed in the view instead
GeoPDF. Send the PDF as is (Content-Type: application/pdf); add
page= for a page other than the first. The server reads the page's georeference, either
the ISO 32000 / Adobe geospatial encoding that Avenza, NRCan, USGS and QGIS/GDAL write, or the older
OGC/TerraGo LGIDict. It then renders the page at 300 dpi (at most 8192 px a side),
cropped to the map frame, with everything outside the neatline transparent:
"georef": "geopdf", plus crs when the file names an EPSG code. A page
without a georeference is rendered whole and placed in place= like a plain image, with
"warning": "no_georeference". Every PDF answer includes "pages", the page
count, so you can offer the other pages.
POST /api/v1/maps/SQCWJAM/overlays?page=2 a GeoPDF
→ 201 { …, "overlay": { …, "georef": "geopdf", "crs": "EPSG:26910" }, "pages": 3 }
→ 400 { "error": "bad_page" } no such page
→ 400 { "error": "pdf_encrypted" } password-protected or encrypted: save an unprotected copy
→ 400 { "error": "bad_pdf" } unreadable, or took over 60 s to render
Coordinate systems: WGS 84 (4326), NAD83 (4269), NAD83(CSRS) (4617), Web Mercator (3857),
Canada Atlas Lambert (3979), BC Albers (3005), WGS 84 UTM (32601–32660, 32701–32760), NAD83 UTM
(26901–26923) and NAD83(CSRS) UTM 7N–22N. Draw the image from
/api/v1/media/{mediaId}/medium (WebP, at most 8192 px, transparency kept) onto the
four corners; /thumb is a small JPEG and /api/v1/media/{mediaId} the
original file. Moving or re-aligning an overlay is an ordinary feature update of its geometry and
properties.overlay. Uploading needs view and edit.
Feature format
| class | geometry | useful properties |
|---|---|---|
| Marker | Point | marker-color, description |
| Shape | LineString, Polygon | stroke, stroke-width, fill, description |
| RangeRing | Point (centre) | radii (km, e.g. [1, 2, 5]), stroke |
| Folder | none | other features point at it with folderId |
Coordinates are [longitude, latitude] or [lng, lat, altitude_m], WGS84.
Permissions and errors
| Connection can | Read maps | Write features & photos | Send positions | Webhooks |
|---|---|---|---|---|
| Send positions only | yes | no | yes | yes |
| View | yes | no | no | yes |
| View and edit | yes | yes | yes | yes |
401: no token, or the connection was removed. Stop and ask the user to reconnect.403: the connection may not do that.404: not found, or outside your folder.400: bad input; the body says what.429: too fast; wait and retry.- Errors are JSON:
{ "error": "code", "message": "…" }.
Team data is stored in Canada by a Canadian-owned provider. Your app only gets the maps a team chooses to give it, and the team can see and remove every connection.