Recipes & how-tos
Task-oriented examples for the most common jobs. They assume the environment from the Quickstart:
export ZM_KEY="zm_your_api_key_here"
export ZM_API="https://api.zeemaps.com/v1"
export ZM_MAP=4322001
Add a single pin by coordinates
Only name is required. Supply lat and lng directly:
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Corp HQ", "lat": 37.7879, "lng": -122.3983 }'
Add a single pin by address (server-side geocoding)
Leave out lat/lng and send a structured address. The server geocodes it for you —
there is no separate geocoding endpoint. The address is structured (parts), not one free
text field:
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp HQ",
"address": {
"street": "350 Mission St",
"city": "San Francisco",
"state": "CA",
"zip": "94105",
"country": "US"
}
}'
A request with neither coordinates nor a geocodable address is rejected with
400 BAD_REQUEST.
Reference: POST /maps/{map_id}/markers.
Marker types: pins, regions, and circles
Every marker has a type (default pin), set per marker — a single map can freely mix
pins, region areas, and circles. Region types render as a filled area instead of a pin;
the area is resolved server-side from the marker’s location or postal code.
type |
Renders as | Resolved from |
|---|---|---|
pin |
a pin | lat/lng or geocoded address |
us_zip |
a US ZIP-code area | address.zip |
ca_fsa |
a Canadian FSA area | address.zip |
uk_postcode |
a UK postcode area | address.zip |
au_postcode / de_postcode |
AU / DE postal areas | address.zip |
us_county |
a US county area | lat/lng |
us_state / au_state |
a US / AU state area | lat/lng |
country |
a country area | lat/lng |
circle / pinned_circle |
a circle of a given radius |
center lat/lng + radius |
If a region can’t be matched (e.g. an unknown ZIP), the marker falls back to a pin.
A region marker (US ZIP area)
For postal region types, the zip in the address drives the area:
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "94105", "type": "us_zip", "address": { "zip": "94105", "country": "US" } }'
The response echoes a read-only region_source (e.g. "uszipcode") when an area matched,
or null when it fell back to a pin.
A circle marker
circle and pinned_circle need a radius. Use radius_unit to choose miles (default)
or km — kilometres are normalized to miles for you:
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "50 km service area",
"type": "circle",
"lat": 37.7749, "lng": -122.4194,
"radius": 50, "radius_unit": "km"
}'
Categories, colors, and legends
A marker’s category is its grouping label (e.g. "Customer", "Prospect"). Setting a
category does two things automatically:
- it builds the map’s legend, and
- it assigns the marker a color from the active palette.
So you normally set category and let the color follow. You can override with an explicit
color, which is a palette color name (e.g. "blue", "Hot Pink") — never a hex value.
# color follows the category automatically
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Globex", "lat": 40.71, "lng": -74.00, "category": "Prospect" }'
Discover what a map supports before writing — useful for automated clients:
| Endpoint | Returns |
|---|---|
GET /maps/{map_id}/categories |
categories in use + their legend colors |
GET /maps/{map_id}/colors |
valid palette color names for the map |
GET /maps/{map_id}/fields |
custom field definitions |
Edit or reorder the legend
PUT /maps/{map_id}/categories
replaces the legend with the list you send, in that order — the same model as the web app’s
legend editor. Use it to rename a category (markers keep their color), reassign a
color, or reorder legend entries. Markers are never modified; categories you leave out
are removed from the legend.
Per entry, name is required; color is optional (an existing category keeps its color, a
new one gets the first unused palette color); ordering is optional and defaults to the
array position. To rename a label while keeping its markers’ color, send the new name with
the color’s current assignment (read it from the GET first) — a name the legend doesn’t know
is otherwise treated as a new category and auto-assigned a fresh color.
# put "Customer" first, and rename "Prospect" (currently red) to "Lead"
curl -s -X PUT "$ZM_API/maps/$ZM_MAP/categories" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{
"categories": [
{ "name": "Customer" },
{ "name": "Lead", "color": "red" }
]
}'
To clear the legend entirely (markers keep their colors), use
DELETE /maps/{map_id}/categories.
Extended colors are one-way
A map starts on the standard palette (48 colors). Need more? Enable the extended palette:
curl -s -X POST "$ZM_API/maps/$ZM_MAP/extended-colors" \
-H "Authorization: Bearer $ZM_KEY"
This cannot be undone. Enabling extended colors is one-way — there is no API (and no UI) to revert a map to the standard palette. The call is idempotent if already enabled. After enabling,
GET /maps/{map_id}/colorsreturns the extended names and the map’sextended_colorsreadstrue.
Reference: POST /maps/{map_id}/extended-colors.
Bulk import: three shapes
To add many markers at once, use POST /maps/{map_id}/markers/bulk.
It accepts three request shapes; pick by the data you have. The row limit is the map’s
plan limit (not a fixed number); exceeding it returns 400.
1. JSON array (synchronous for small batches)
Send a markers array of structured markers. Small batches (≤ 500) return 200
immediately with a per-item summary; larger JSON batches are processed asynchronously
(see below).
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: application/json" \
-d '{
"markers": [
{ "name": "Acme", "address": { "street": "350 Mission St", "city": "San Francisco", "state": "CA", "zip": "94105" }, "category": "Customer" },
{ "name": "Globex", "lat": 40.7128, "lng": -74.0060, "category": "Prospect" }
],
"on_duplicate": "skip"
}'
{ "added": 2, "skipped": 0, "errors": [] }
on_duplicate controls what happens when an incoming marker matches an existing one on
name + coordinates: skip (default), overwrite, or error. (There is no database
uniqueness constraint on markers; matching mirrors the importer’s overwrite behavior.)
2. Raw CSV with ZeeMaps headers (asynchronous)
If you already have a CSV whose headers use ZeeMaps field names, post it as text/csv. Any
CSV upload is handed to the shared import service and processed asynchronously — you get
202 + a job handle.
Columns are recognized the same way the web upload recognizes them, in this order per column:
- Exact API field names —
name,street,street2,city,state,zip,country,county,lat,lng,category,radius,photo_url,photo_width,photo_height,youtube_id,video_width,video_height,expire,location_code— plus the display names ZeeMaps itself exports (“Street Address”, “Post Code”/“Zipcode”, “Category (Color)”/“Color”, “Latitude”, “Longitude”, “Photo URL”, “Photo Width”, “Photo Height”, “YouTube”, “Video Width”, “Video Height”, …), so an exported spreadsheet round-trips. - Header memory — a header row whose column assignments were previously confirmed in the web upload UI reproduces that confirmed mapping.
- AI classification — remaining columns are classified by an AI header inferrer (with a regex fallback if the AI call fails), exactly like the web upload.
Anything still unrecognized (and not ignored) becomes a custom field named after its
header. Set auto_map=false (query parameter; "auto_map": false in the multipart
meta) to skip steps 2–3 and get strict, deterministic exact-name matching only.
The JSON-array shape (recipe 1) defaults to auto_map=false — its custom_fields
are stored verbatim as custom fields — unless you opt in with auto_map=true.
Latency note: the AI classification runs synchronously before the
202response and typically adds ~0.5–2 s. It is skipped when every column already resolves via exact names or an explicitcolumn_mapping, and never runs withauto_map=false. An AI failure is fail-open: the import still runs using the regex fallback.
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" \
-H "Content-Type: text/csv" \
--data-binary $'name,street,city,state,zip,category\nAcme Corp,350 Mission St,San Francisco,CA,94105,Gold'
{
"job_id": "imp_7b2c91",
"kind": "bulk_import",
"status": "queued",
"status_url": "https://api.zeemaps.com/v1/jobs/imp_7b2c91"
}
Poll the job until it completes (see Polling a job).
3. Multipart with a column mapping (arbitrary spreadsheets)
When the spreadsheet’s columns don’t match ZeeMaps field names, send
multipart/form-data with the file plus a meta JSON whose column_mapping maps each
ZeeMaps field to a column (by header name or 0-based index). This is the API equivalent of
the upload → “Confirm column assignments” step. An explicit mapping always wins for the
columns it names; the rest go through the same recognition steps as the raw-CSV shape
above (disable the auto-map steps with "auto_map": false in meta). If a mapping
entry happens to read validly in both the documented {apiField: columnRef} direction
and the legacy pre-2026 {spreadsheetColumn: apiField} direction (e.g. {"city": "name"} when the sheet has both city and name columns), the legacy reading wins so
older integrations keep working; use a 0-based index ref ({"city": 0}) to force the
documented reading. Columns you
neither map nor ignore become custom fields named after their header. The file must have
a header row (v1 always treats the first row as headers).
curl -s -X POST "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" \
-F "file=@customers.csv;type=text/csv" \
-F 'meta={
"marker_type": "pin",
"default_country": "US",
"on_duplicate": "overwrite",
"column_mapping": {
"name": "Company",
"street":"Address 1",
"city": "Town",
"state": "Region",
"zip": "Postcode",
"category": "Segment"
}
};type=application/json'
Returns 202 + a BulkJob, same as the CSV shape. meta also supports default_radius
and radius_unit for circle imports.
Polling an async job
Both bulk imports and image renders are async jobs polled at the shared
GET /jobs/{job_id} endpoint; the
kind field tells the two apart (bulk_import vs image). Jobs are owned-only — a job
whose map you don’t own returns 404.
curl -s "$ZM_API/jobs/imp_7b2c91" -H "Authorization: Bearer $ZM_KEY"
{ "job_id": "imp_7b2c91", "kind": "bulk_import", "status": "completed", "added": 1, "skipped": 0 }
Bulk delete and clear
DELETE /maps/{map_id}/markers/bulk
removes many markers in one call. Provide exactly one selector and "confirm": true
(a missing confirm is rejected with 400).
# delete by explicit ids
curl -s -X DELETE "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "marker_ids": [88341027, 88341028], "confirm": true }'
# clear the entire map
curl -s -X DELETE "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "all": true, "confirm": true }'
# delete every marker in some categories and/or colors
curl -s -X DELETE "$ZM_API/maps/$ZM_MAP/markers/bulk" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "categories": ["Prospect"], "colors": ["blue"], "confirm": true }'
{ "deleted": 2 }
To reset the legend without deleting markers (they keep their colors), use
DELETE /maps/{map_id}/categories,
which returns 204.
Searching markers
GET /maps/{map_id}/markers/search
combines text, category, and geographic filters. Results are paginated like any list (see
Pagination).
| Filter | Form | Matches |
|---|---|---|
q |
q=acme |
text across name, address, and custom fields (substring) |
category |
category=Customer |
markers in that category |
bounds |
bounds=sw_lat,sw_lng,ne_lat,ne_lng |
markers inside a bounding box |
near |
near=lat,lng,radius_km |
markers within radius_km kilometres of a point |
# text + category
curl -s -G "$ZM_API/maps/$ZM_MAP/markers/search" \
-H "Authorization: Bearer $ZM_KEY" \
--data-urlencode "q=acme" --data-urlencode "category=Customer"
# within a bounding box
curl -s -G "$ZM_API/maps/$ZM_MAP/markers/search" \
-H "Authorization: Bearer $ZM_KEY" \
--data-urlencode "bounds=37.2,-122.5,37.9,-121.8"
# within 50 km of a point
curl -s -G "$ZM_API/maps/$ZM_MAP/markers/search" \
-H "Authorization: Bearer $ZM_KEY" \
--data-urlencode "near=37.77,-122.41,50"
Sharing a map
Sharing is owner-side: you grant other people access to a map you own. Levels are viewer,
member, or admin. Inviting an email with no ZeeMaps account creates a pending account
and sends a set-password link.
# share
curl -s -X POST "$ZM_API/maps/$ZM_MAP/shares" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "email": "teammate@acme.example", "access_level": "member" }'
# list shares
curl -s "$ZM_API/maps/$ZM_MAP/shares" -H "Authorization: Bearer $ZM_KEY"
# change a share's level
curl -s -X PATCH "$ZM_API/maps/$ZM_MAP/shares/512" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "access_level": "admin" }'
# revoke a share (204)
curl -s -X DELETE "$ZM_API/maps/$ZM_MAP/shares/512" -H "Authorization: Bearer $ZM_KEY"
Sharing affects who can use a map in the web app; it does not grant another account’s API key access to the map. The API stays owned-only.
Reference: shares operations.
Archive, restore, and transfer
Maps have a lifecycle beyond create/delete:
# archive (moves it to the archived view)
curl -s -X POST "$ZM_API/maps/$ZM_MAP/archive" -H "Authorization: Bearer $ZM_KEY"
# list archived maps
curl -s "$ZM_API/maps?status=archived" -H "Authorization: Bearer $ZM_KEY"
# restore to active (paid feature)
curl -s -X POST "$ZM_API/maps/$ZM_MAP/restore" -H "Authorization: Bearer $ZM_KEY"
# transfer ownership to another account
curl -s -X POST "$ZM_API/maps/$ZM_MAP/transfer" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "new_owner_email": "newowner@acme.example" }'
- Delete (
DELETE /maps/{map_id}) is a soft delete: the map leaves your lists but is recoverable via restore. Returns204. - Archive / restore move a map between the active and archived views.
- Transfer reassigns ownership; after a transfer the old owner loses API access and the new owner gains it (a pending account is created if the email is new).
Restore and transfer are paid operations — non-paid accounts get 402/403.
Render a map image
POST /maps/{map_id}/images
renders a standard map image (PNG or PDF). Rendering is heavyweight, so it’s always async:
you get 202 + an ImageJob, then poll the jobs endpoint and
download image_url when status is completed. v1 offers fixed layouts via preset
(pin_coverage, us, world, regions) — no custom bounds or sizes. Images are
un-watermarked and covered by the enterprise plan.
curl -s -X POST "$ZM_API/maps/$ZM_MAP/images" \
-H "Authorization: Bearer $ZM_KEY" -H "Content-Type: application/json" \
-d '{ "preset": "us", "format": "png", "legend": true }'
Related
- Quickstart · Conventions · Pagination · Errors
- Field-by-field detail for every endpoint: the API Reference.