CalTopo has a supported API for Teams. Below you will find information on how the Team Admin can get the credential secret, credential id, and your team ids, how to sign requests, and some sample code .
Topics on this Page:
The CalTopo API is built around service accounts. You cannot log in to these accounts, but permissions and visibility into parent and child teams works the same as tradditional user accounts. For details on team membership levels, see this this guide.
All CalTopo accounts (team, individual and service) are identified by a 6-digit alphanumeric ID. Accounts also have credentials associated with them, allowing secure access to the API. While individual accounts may have multiple credentials, for example one for each device a user has signed in on, service accounts are currently limited to a single credential per account. These credentials consist of two parts, an ID included with each request to identify the credential being used, and a secret which is never sent to the server, but is used to sign requests. These are obtained when an admin creates a service account on the Team Admin Page. While the ID can be viewed later, the secret appears only once and must be copied and stored securely at the time of creation.
In addition to other endpoint-specific parameters, all API requests include an expiration time (in epoch milliseconds), the ID of the credential used to sign the request, and an HMAC-SHA256 based signature for authentication. This script shows how to generate a signed request, and providing a basic building block for all API requests.
import base64
import hmac
import json
import time
import urllib.error
import urllib.request
from urllib.parse import urlencode
# Global variables
CREDENTIAL_ID = "your auth ID"
CREDENTIAL_SECRET = "your credential secret"
DEFAULT_TIMEOUT_MS = 2 * 60 * 1000
def sign(method, url, expires, payload_string, credential_secret):
"""
Generates an HMAC signature for API request authentication.
Parameters:
method (str): HTTP method (e.g., "GET", "POST").
url (str): API endpoint URL.
expires (int): Expiration time in milliseconds.
payload_string (str): JSON payload as a string.
credential_secret (str): Credential Secret.
Returns:
str: Base64-encoded signature.
"""
message = f"{method} {url}\n{expires}\n{payload_string}"
secret = base64.b64decode(credential_secret)
signature = hmac.new(
secret, message.encode(), "sha256"
).digest()
return base64.b64encode(signature).decode()
def caltopo_request(method, endpoint, credential_id, credential_secret, payload=None):
"""
Prepares and sends an authenticated API request.
Parameters:
method (str): HTTP method (e.g., "GET", "POST").
url (str): API endpoint URL.
payload (dict): Data to send with the request.
credential_id (str): Credential ID.
credential_secret (str): Credential Secret .
Returns:
dict or None: API response as JSON, or None
if an error occurs.
"""
payload_string = json.dumps(payload) if payload else ""
expires = int(time.time() * 1000) + DEFAULT_TIMEOUT_MS
signature = sign(
method, endpoint, expires, payload_string, credential_secret
)
parameters = {
"id": credential_id,
"expires": expires,
"signature": signature,
}
if method.upper() == "POST" and payload is not None:
parameters["json"] = payload_string
query_string = ""
else:
query_string = f"?{urlencode(parameters)}"
url = f"https://caltopo.com{endpoint}{query_string}"
body = (
urlencode(parameters).encode()
if method.upper() == "POST" and payload
else None
)
request = urllib.request.Request(
url, data=body, method=method.upper()
)
request.add_header(
"Content-Type", "application/x-www-form-urlencoded"
)
if body is not None:
request.add_header("Content-Length", str(len(body)))
with urllib.request.urlopen(request) as response:
response_data = response.read().decode("utf-8")
if response_data:
return json.loads(response_data).get("result")
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
// Global variables.
public static final String CREDENTIAL_ID = "your credential ID";
public static final String CREDENTIAL_SECRET = "your credential secret";
public static final int DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
public static String sign(
String method,
String url,
long expires,
String payload,
String credentialSecret
) {
try {
// Construct the message
String message = method + " " + url + "\n"
+ expires + "\n"
+ (payload != null ? payload : "");
// Decode the authentication key (Base64)
byte[] secretKey = Base64.getDecoder().decode(credentialSecret);
// Create a Mac instance with the HMAC-SHA256 algorithm
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secretKey, "HmacSHA256");
hmac.init(keySpec);
// Generate the signature
byte[] signatureBytes = hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
// Return the signature as a Base64-encoded string
return Base64.getEncoder().encodeToString(signatureBytes);
} catch (Exception e) {
throw new RuntimeException("Error while generating HMAC signature", e);
}
}
public static String encodeParams(Map params) {
String paramString = "";
for (Map.Entry entry : params.entrySet()) {
paramString += entry.getKey() + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8) + "&";
}
return paramString.substring(0, paramString.length() - 1);
}
public static JSONObject caltopoRequest(
String method,
String url,
String payload,
String credentialId,
String credentialSecret
) {
try {
method = method.toUpperCase();
long expires = System.currentTimeMillis() + DEFAULT_TIMEOUT_MS;
// Generate the signature
String signature = sign(method, url, expires, payload, credentialSecret);
// Construct the query string
String query = "";
Map params = new HashMap<>();
params.put("id", credentialId);
params.put("expires", String.valueOf(expires));
params.put("signature", signature);
if (method.equals("POST") && payload != null) {
params.put("json", payload);
} else {
query = "?" + encodeParams(params);
}
// Construct the full URL
String fullUrl = "https://caltopo.com" + url + query;
// Open a connection
HttpURLConnection connection = (HttpURLConnection) new URL(fullUrl).openConnection();
connection.setRequestMethod(method);
if (method.equals("POST") && payload != null) {
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
connection.setDoOutput(true);
OutputStream os = connection.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
dos.writeBytes(encodeParams(params));
dos.flush();
dos.close();
os.close();
}
// Get the response code
int responseCode = connection.getResponseCode();
BufferedReader reader;
// Handle the response stream
if (responseCode == HttpURLConnection.HTTP_OK) {
reader = new BufferedReader(new InputStreamReader(connection.getInputStream(),
StandardCharsets.UTF_8));
} else {
reader = new BufferedReader(new InputStreamReader(connection.getErrorStream(),
StandardCharsets.UTF_8));
}
// Read the response
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
// Return the response if HTTP_OK, otherwise throw an error
if (responseCode == HttpURLConnection.HTTP_OK) {
JSONObject responseJson = new JSONObject(response.toString());
return responseJson.getJSONObject("result");
} else {
throw new RuntimeException("Request failed with code " + responseCode
+ ": " + response);
}
} catch (Exception e) {
throw new RuntimeException("Error during CalTopo request", e);
}
}
GET https://caltopo.com/api/v1/acct/{team_id}/since/{timestamp}
CalTopo's data model is broadly divided between account objects like maps and PDFs, and map objects like markers and shapes that live on a specific map. This sections shows you how to retrieve a list of all account objects, across all subteams a team has visibility into. The API will only return objects created or changed since the provided timestamp; if you are making multiple requests to this endpoint per minute, please use a recent timestamp value for lighter weight, more performant requests. The Service Account must have at least ADMIN permission.
Parameter | Type | Description |
---|---|---|
team_id | string | The ID of the team to query. |
timestamp | integer | A Unix timestamp in milliseconds. Use 0 to retrieve all current data, or provide a timestamp to get changes since that time. Use the response timestamp in future requests to fetch updates since the last request. |
You can use the caltopo_request function provided above to access this endpoint as shown below.
team_id = "your team ID"
ts = 0 # Replace with a Unix timestamp in milliseconds if needed
team_data = caltopo_request(
"GET",
f"/api/v1/acct/{team_id}/since/{ts}",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
)
The server's response is a wrapped GeoJSON FeatureCollection, with each account object appearing as an entry in the features array. If the server is not sure whether or not an account object has been deleted since the provided timestamp, it will also include an ids map enumerating all account object IDs, so that clients can determine if the account no longer has access to a given object. The response will additionally include a groups array with an entry for each subteam the service account has visibility into, an accounts array that duplicates this list with the service account added, and a rels array representing both subteams and bookmarked maps; these entries are used by the CalTopo mobile app but should be ignored for API access purposes.
Click to expandAs one example use case, the Python code below shows how to iterate through the response and develop a list of all maps the team has access to.
# Extract a list of map IDs, map titles, and the teams that own them
collaborative_maps = {
feature["id"]: feature
for feature in team_data["features"]
if feature["properties"].get("class") == "CollaborativeMap"
}
After getting the data from the Team and parsing it to get the colabrative map info you can then retrieve that data and save it to a file.
for map_id, map_info in collaborative_maps.items():
map_data = caltopo_request(
"GET",
f"/api/v1/map/{map_id}/since/0", CREDENTIAL_ID, CREDENTIAL_SECRET
)
title = map_info['properties']['title']
team_id = map_info['properties']['accountId']
with open(
f"{map_id}_{title}_{team_id}.json", "w") as fp:
json.dump(map_data["state"], fp)
POST https://caltopo.com/api/v1/acct/{team_id}/CollaborativeMap
This endpoint creates a new Collaborative Map and adds features such as a markers, lines, and polygons to it. The input to this endpoint is a single JSON `Map` object that includes the necessary properties and features. The Service Account must have at least UPDATE permission.
Parameter | Type | Description |
---|---|---|
team_id | string | The ID of the team you want the map in. |
To create a map and save it to a team or sub-team, first obtain the team’s ID. One way is to check the URL on the team's admin page:
https://caltopo.com/group/{team_id}/admin/details
Alternatively, extract the team ID from the account data using the sample Python code below:
teams = [[team["id"], team["properties"].get("title")]
for team in team_data.get("accounts", [])
if team.get("properties", {}).get("title")]
Use the team's ID with the credential ID and secret to create or update a map. The team ID is only needed to obtain the team ID.
The "Map" object defines the map's properties, sharing settings, and a collection of features to be added. Below is the structure of the "Map" object:
Field | Type | Description |
---|---|---|
properties | object |
Defines the map's:
|
state | object | Contains the FeatureCollection of map features such as markers, lines, and polygons. The format follows the standard GeoJSON format. |
The state object is a GeoJSON `FeatureCollection` that contains the following features:
Other feature types are not supported and may result in unexpected or undesired behavior.
To match the base layers and overlays of another map in the team, retrieve its activeLayers using the code below.
map_id = "your map id"
active_layers = json.loads(
collaborative_maps[map_id]['properties']['config']
)['activeLayers']
To create a map, use the `sign` and `caltopo_request` along with the code below.
team_id = "your team ID"
caltopo_request(
"POST",
f"/api/v1/acct/{team_id}/CollaborativeMap",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
json.loads(map_json)
)
Deleting a map is straightforward using the CalTopo API. You need the map ID and the team ID of the map you want to delete.
DELETE https://caltopo.com/api/v1/acct/{team_id}/CollaborativeMap/{map_id}
This sends a DELETE request, the Service Account must have at least MANAGE permission.
Parameter | Type | Description |
---|---|---|
map_id | string | The ID of the map to query. |
team_id | string | The ID of the team the map is in. |
The example below demonstrates how to delete a map.
team_id = "your team ID"
map_id = "your map ID"
caltopo_request(
"DELETE",
f"/api/v1/acct/{team_id}/CollaborativeMap/{map_id}",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
)
GET https://caltopo.com/api/v1/map/{map_id}/since/{timestamp}
This section shows how to retrieve objects belonging to a particular map. As with the account endpoint above, a timestamp allows incremental fetches of only updates since the given time, and should be used when the endpoint will be subjected to frequent queries.
The Service Account must have at least READ access to view features and metadata.
Parameter | Type | Description |
---|---|---|
map_id | string | The ID of the map to query. |
timestamp | integer | A Unix timestamp in milliseconds. Use 0 to retrieve all current data. Responses include a server timestamp that can be used on a subsequent request to only obtain incremental changes. |
You can use the caltopo_request function provided above to access this endpoint as shown below.
map_id = "your map id"
ts = 0 # Replace with a Unix timestamp in milliseconds if needed
map_data = caltopo_request(
"GET",
f"/api/v1/map/{map_id}/since/{ts}",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
)
As with the account object endpoint, the server's response is a wrapped GeoJSON feature collection, and if the server is not sure whether a map object has been deleted since the provided timestamp, a map of object IDs so that the client can identify any objects it has which have been deleted on the server.
Once a map is created, any edits to its contents are done individually to each object; there is no way to send a batch of changes to a single endpoint.
Add a Map Object
POST https://caltopo.com/api/v1/map/{map_id}/{object_type}
Edit a Map Object
POST https://caltopo.com/api/v1/map/{map_id}/{object_type}/{object_ID}
Delete a Map Object
DELETE https://caltopo.com/api/v1/map/{map_id}/{object_type}/{object_ID}
This endpoints allow you to edit or add a new object to an existing map. To add a new object the request should include a JSON object with the object's name, coordinates, and properties.
To edit an object the request should include a JSON object with the object's name, object Id and properties.
The Service Account must have at least UPDATE permission to use this endpoint.
Parameter | Type | Description |
---|---|---|
map_id | string | The ID of the map to which the marker is being modified. |
object_type | string | The type of map object to modify, e.g.:
|
object_Id | string | The ID of the object you want to modify. |
Editing or adding to a map is similar to creating one, with the key difference being the need for the map ID. Find the map ID by opening the map in your browser and checking the URL.
https://caltopo.com/m/{map_id}
Alternatively, retrieve the map ID from the account data, as demonstrated earlier when extracting a list of map IDs and titles from the data.
The object id can be acquired from the map data we got when we retrieved the map data above.
To use the example below, ensure that you include the sign and caltopo_request functions in your code. This example demonstrates how to add a marker to a map.
map_id = "your map id"
caltopo_request(
"POST",
f"/api/v1/map/{map_id}/Marker",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
json.loads(add_marker_data)
)
The code used to import a photo into a map below relies on the sign and caltopo_request function. The Service Account must have at least UPDATE permission
import uuid
map_id = "map_id"
team_id = "team_id"
photo_path = "photo_path"
"""Create backend media object."""
media_id = str(uuid.uuid4())
media_metadata_payload = {
"properties": {
"creator": team_id
}
}
caltopo_request(
"POST", f"/api/v1/media/{media_id}",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
media_metadata_payload
)
"""Upload media data."""
with open(photo_path, "rb") as img_file:
base64_data = base64.b64encode(img_file.read()).decode()
media_data_payload = {"data": base64_data}
caltopo_request(
"POST", f"/api/v1/media/{media_id}/data",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
media_data_payload
)
"""Attach media object to map."""
media_object_payload = {
"type": "Feature",
"id": None,
"geometry": {
"type": "Point",
"coordinates": [-119, 39]
},
"properties": {
"parentId": "",
"backendMediaId": media_id,
"title": "Photo Title",
"heading": None,
"description": "",
"marker-symbol": "aperture",
"marker-color": "#FF0000",
"marker-size": 1,
}
}
caltopo_request(
"POST", f"/api/v1/map/{map_id}/MapMediaObject",
CREDENTIAL_ID,
CREDENTIAL_SECRET,
media_object_payload
)