This guide covers the read-only map data API: how to create credentials, authenticate, find out what data a layer holds, and query it.
- Base URL:
https://api.cartographer.io - Interactive reference: https://api.cartographer.io/docs — the supported endpoints, with every parameter and response schema. This guide is the narrative version of the same thing.
- Web app: https://app.cartographer.io
Everything below is read-only. The API can list the map layers in a project and search, count, pick or download their features as GeoJSON, JSON, CSV, Excel, KML or Mapbox vector tiles. It does not write survey data.
1. Create a service account#
A service account is a non-human account with its own roles and its own access token. Use one for anything automated — a website, a dashboard, a GIS tool, a scheduled export — rather than your own login.
In the web app:
- Go to your project and open Settings → Integrations → Service Accounts.
- Click Add Service Account.
- Give it a Name and, ideally, a Description saying what it’ll be used for. A future version of you will want to know before cycling a token.
- Save. The token is displayed once, at the top of the page.
A new service account is created with the Project Map Viewer role for the project you created it in, which is exactly enough to read map data and nothing else. Keep it that way unless you have a specific reason not to.
Public and secret tokens#
The type you choose decides whether the token can ever be retrieved again, and that is the whole difference.
Public (pk_…) | Secret (sk_…) | |
| Shown | Whenever you like, from the Service Accounts page | Once, when it is created or cycled |
| Stored as | A recoverable, signed value | A one-way hash — we cannot read it back |
| Use it for | A token that will be visible anyway: a public map on your own website, an embedded dashboard | Server-side integrations, scheduled jobs, anything you can keep in a secret manager |
| If you lose it | Look it up again | Cycle it and update whatever used it |
A token looks like this:
pk_live_v1_5f2c1e9a8b7d4c3e9a1b2c3d4e5f6a7b_QmxhaCBibGFoIGJsYWgThe second segment is the environment — live or test. A token minted in one environment does not work in the other.
A public token is still a credential: it carries whatever roles the service account has. "Public" means it is safe to show, not that it is safe to over-privilege. Give it the narrowest role that does the job, and prefer the temporary tokens in section 3 for anything that runs in a browser.
Cycling and deleting#
Cycle replaces the token and deactivates the old one immediately — anything still using the old token starts failing straight away, so update your integrations first. Delete removes the service account and its access altogether. Both are on the Service Accounts page.
2. Authorize your requests#
Send the token as a bearer token:
curl -H "Authorization: Bearer pk_live_v1_5f2c…" \
"https://api.cartographer.io/map/layer/v2?project=river-monitoring"If the Authorization header is already taken — the usual case is a Mapbox GL map, whose tile requests have to authenticate to Mapbox — send it as Cartographer-Authorization instead:
Cartographer-Authorization: Bearer pk_live_v1_5f2c…When both are present, Cartographer-Authorization wins.
Some things to expect:
401 Unauthorizedmeans the token is missing, malformed, or expired.403 Forbiddenmeans the token is fine but the account it belongs to may not do what you asked. Check the service account's roles, and check that you passed?project=.- Anonymous requests are allowed against a project whose maps are public, and return only approved records. If that is all you need, you do not need a token at all.
3. Issue a temporary token (optional)#
If your long-lived token would end up somewhere you would rather it did not — a browser, a shared notebook, a colleague's script — exchange it for a short-lived one server-side and hand that out instead:
curl -X POST \
-H "Authorization: Bearer sk_live_v1_5f2c…" \
"https://api.cartographer.io/auth/v3/token"The response body is the new token as a quoted JSON string (or as unquoted plain text if you pass a header of Accept: text/plain):
"tk_live_v1_9d8c7b6a5e4f3d2c1b0a9f8e7d6c5b4a_VGVtcG9yYXJ5IHRva2Vu"What you get:
- The same account and the same roles as the credential you called it with. It is a disposable copy, not a reduced one — it cannot see less than the service account behind it.
- A 24-hour lifetime.
- No self-renewal. A temporary token cannot mint another temporary token; the call has to be made with the service account's own token. That is what stops a leaked temporary token from renewing itself indefinitely.
- A rate limit of 10 mints per account per 10 minutes. Mint one per session, not one per request.
4. Find out what is in a layer#
A layer is one queryable collection of features — mrsMorph, say. Its attributes are the fields you can select, label, sort and filter on, so this is the call to make before writing a query.
curl -H "Authorization: Bearer pk_live_v1_5f2c…" \
"https://api.cartographer.io/map/layer/v2?project=rivermy-project&workspace=my-workspace"?project= takes the project's id or its alias. If you use the alias, add ?workspace= so we know which workspace to look it up in. You will get back the layers that project has registered — the layers of the maps it holds, plus the form layers of its survey modules — which is exactly the set you are entitled to search. ?map= narrows the list further to one map.
Omitting ?project= asks for every layer in Cartographer, which only a global map viewer may do, so an ordinary service account gets a 403. Naming a project that does not exist is a 404.
The response:
{
"results": [
{
"layerId": "mrsMorph",
"names": { "shortName": "MoRPh", "longName": "MoRPh Survey" },
"title": "MoRPh Survey",
"geometryType": "Point",
"attribution": ["Modular River Survey"],
"attributes": [
{
"name": "surveyId",
"attributeType": { "type": "Uuid" },
"label": "Survey",
"unit": null,
"tags": ["@survey"]
},
{
"name": "timestamp",
"attributeType": { "type": "Timestamp" },
"label": "Date/Time",
"unit": null,
"tags": ["@timestamp"]
},
{
"name": "channelWidth",
"attributeType": { "type": "Nullable", "param": { "type": "Number" } },
"label": "Channel width",
"unit": "m",
"tags": []
}
]
}
],
"total": 1
}Reading the attributes:
nameis what you use in?properties=,?order=and an ECQL?filter=.labelis the human-readable heading, andunitis its unit where it has one.attributeTypeis a tagged object. The simple ones areBoolean,Integer,Number,String,Uuid,Timestamp,JsonandGeometry.Enumcarriesvalues(and optionallabels).Nullablewraps another type inparam— so a nullable number is{"type": "Nullable", "param": {"type": "Number"}}rather than a flag on the attribute.tagsare stable aliases for the attributes that mean the same thing in every layer:@survey,@surveyor,@project,@team,@statusand@timestamp. Anywhere a parameter takes an attribute name it also takes a tag, so?properties=@timestamp,@surveyworks against any layer without knowing its field names.
GET /map/layer/v2/{layerId} returns one layer in the same shape.
Likely to change. The shape of the layer structure response —
attributesand theattributeTypeencoding in particular — is the part of this API we are least happy with, and we expect to change it. Read the attributes you need bynameor by tag rather than by position, keep the parsing in one place, and expect to revisit it. We will give notice before it moves.
5. Search a layer#
curl -H "Authorization: Bearer pk_live_v1_5f2c…" \
"https://api.cartographer.io/map/feature/v2/mrsMorph?project=my-project&workspace=my-workspace&format=geojson"This is the main way to get data out of Cartographer. Everything else on /map/feature/v2 is a variation on it.
Say which project. ?project= is optional in the schema but required in practice: omitting it means "every project in the system", which only a global map viewer may ask for, so without it you get a 403. It takes an id or an alias, and may be repeated — ?project=a&project=b — to search several at once. Add ?workspace= when you are using aliases.
The layer has to belong to the project. Layers are Cartographer-wide, but each project registers the ones it actually uses. Asking for a layer your project has not registered is a 404, the same as asking for one that does not exist. Section 4 lists exactly what you may ask for.
Narrowing the search#
| Parameter | What it does |
?project= | Project id or alias. Repeatable. Required in practice. |
?workspace= | Workspace id or alias — needed to resolve a project alias. |
?bbox= | A bounding box, as west,south,east,north in WGS84 — matching OGC API - Features. Note the axis order: lng-first. |
?datetime= | An RFC 3339 instant or an interval: start/end, start/.., ../end. Both ends inclusive. A bare date is that whole day, so ?datetime=2026-03-04 is the whole of the 4th. |
?team= | Comma-separated team ids or aliases. The literal none selects features with no team, and mixes: ?team=riverfly,none. An alias needs ?workspace= to resolve it, and a layer that records no team at all refuses the filter rather than ignoring it. |
?status= | Comma-separated survey statuses: draft, complete, approved, review, rejected. Narrows what you may already see — it cannot widen it. |
?survey= | Comma-separated survey ids. |
?surveyor= | The account id of the surveyor who recorded the record. |
?filter= | An ECQL predicate over the layer's attributes — see below. |
?order=, ?skip=, ?limit= | Sorting and paging. ?order=-timestamp sorts descending; ?order=distanceTo(51.4,-2.5) sorts by proximity. |
Choosing the output#
| Parameter | Values |
?format= | geojson (default), json, tabular-json, csv, xlsx, kml. Takes precedence over a file extension on the layer segment, so /mrsMorph.csv and /mrsMorph?format=csv are the same request. |
?properties= | Comma-separated attribute names or tags, in order. Defaults to everything. |
?naming= | names (computer-friendly, the default) or labels (human-friendly). |
?propertylabels= | Per-column heading overrides: name:label,name:"multi word label". |
?propertyfilters= | Per-column formatting overrides. |
?temporalformat= | datetime, date, time, date-and-time, iso8601, epoch. |
?tz= | utc or europe-london. |
?spatialformat= | native, wkt, osgb36-wkt, latlon, lat-lon, lonlat, lon-lat, ukgridref, osgb36-xy, easting-northing. |
?idformat= / ?attachmentformat= | values (raw ids) or urls (links into the web app). |
?simplify= | Simplify linear and polygonal geometry to points. |
The other four ways in#
The filters above are not just the search's. count, pick and tile take the same ones, so you can count a filtered set before paging it, pick within it, or point a map at it.
GET /map/feature/v2/{layer}/count— how many features a search would return, as a bare JSON integer. Every filter, including?bbox=; the rendering parameters are not accepted. Count first, page second.GET /map/feature/v2/{layer}/pick/{lat,lng}— the single feature nearest a point, which is what a map click should select.?radius=is in metres; without it the search is a one-metre circle, i.e. a hit test. Takes every filter except?bbox=, since the point and radius are already the spatial one — so "the nearest approved feature recorded by my team" is one request.GET /map/feature/v2/{layer}/{featureId}— one feature by thefeatureIdthat every search and tile result carries. Takes no filters at all: the id identifies the feature, and OGC API - Features takes none on the equivalent path either.GET /map/feature/v2/{layer}/tile/{z}/{x}/{y}— a Mapbox vector tile, asapplication/x-protobuf. Point a Mapbox GL vector source at this path with{z}/{x}/{y}left in place and add?project=. Takes every filter except?bbox=, which the tile gets from its own coordinates, so a filtered tile URL keeps its filters as the user pans.?promoteId=defaults tofeatureId, which is what makes feature state work.
What you get back#
Within the projects you named, results are narrowed to what your credential may see rather than refused. Unapproved surveys appear only if the account has a role in the project that reaches them, and in a project holding sensitive data you see records belonging to you or to teams you are in. An anonymous caller can search a project whose maps are public and sees only approved records. A feature you may not see is reported as 404 rather than 403, so the response cannot be used to probe for data in projects you have no access to.
6. Filtering with ECQL#
?filter= takes an ECQL predicate over the layer's attributes — the escape hatch when the named parameters do not express what you need:
?filter=channelWidth > 5 AND status = 'Approved'
?filter=siteName LIKE 'Avon%'
?filter=channelWidth BETWEEN 2 AND 8
?filter=notes IS NOT NULLSupported: the comparison operators, AND / OR / NOT, IN and NOT IN, LIKE and ILIKE, IS NULL and IS NOT NULL, BETWEEN, temporal comparisons against a timestamp literal, INCLUDE / EXCLUDE, and the function strEqualsIgnoreCase(attribute, 'value').
Not supported: spatial predicates (INTERSECTS, WITHIN, DWITHIN, BEYOND, BBOX and the rest), id predicates and existence predicates. These parse and are then rejected with an error rather than quietly ignored, so you will know. Filter spatially with ?bbox= instead, and sort by proximity with ?order=distanceTo(lat,lng).
Remember to URL-encode the predicate.
Likely to change. We intend to retire ECQL. It will be replaced with a filter syntax of our own that matches the rest of the query parameters and is easier to get right. If you can express what you need with the named parameters in section 5 — dates, bounding boxes, survey, surveyor, team — prefer those: they are not going anywhere. If you do use
?filter=, keep the predicates in one place in your code so you have one thing to rewrite. We will give notice, and ECQL will keep working until we do.
7. Summary of what may change#
The endpoints and parameters in sections 1–5 are stable, and we will give notice before anything in them moves. Two things are worth designing around:
- ECQL (
?filter=) will be retired and replaced. Prefer the named filter parameters where they suffice. - The layer structure format will change — the
attributesarray returned by/map/layer/v2in particular. Keep your parsing of it in one place.
If you are building something that depends on either, tell us what you are doing and we will keep you posted specifically.
