ZeeMaps API Docs v1

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:

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}/colors returns the extended names and the map’s extended_colors reads true.

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:

  1. Exact API field namesname, 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.
  2. Header memory — a header row whose column assignments were previously confirmed in the web upload UI reproduces that confirmed mapping.
  3. 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 202 response and typically adds ~0.5–2 s. It is skipped when every column already resolves via exact names or an explicit column_mapping, and never runs with auto_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" }'

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 }'