Maps
GeoJSON maps that render the same way in chat, direct messages, tickets, knowledge pages and the assistant panel.
Maps¶
A map in JuhJuh is GeoJSON. Whatever you send draws the same way in a channel, in a direct message, on a ticket, on a knowledge page and in the assistant panel, because every one of those surfaces hands the same data to the same renderer.
There are four ways to put a map on screen. They are ordered by effort, and the first one takes none.
- Upload a file — drag a
.geojsoninto a channel - Post a map card to a webhook — and re-POST to update it live
- Let an agent answer with a map — register your API once
- Paste a fenced block — into a ticket, comment or knowledge page
Then the reference: the spec, feature properties,
the track shortcut, limits,
and choosing a map provider.
1. Upload a file¶
Drag a .geojson, .kml or .gpx file into a channel. Expand the attachment
and it draws as a map. Nothing to configure and no integration to write, which
makes this the fastest way to see whether the shape of your data is right.
What converts
| Format | What we read |
|---|---|
.geojson |
Any GeoJSON root: a FeatureCollection, a single Feature, or a bare geometry |
.kml |
Each Placemark becomes a feature. Point, LineString and Polygon are read; name becomes the popup heading and description its body. A MultiGeometry becomes several features |
.gpx |
<wpt> becomes a point, each <trk>/<trkseg> becomes a route. name, desc and time carry across |
What does not
.topojson. The file is accepted and previewed as text, but it does not draw. Its coordinates are arc-encoded, so anything short of a full decoder would put a map on screen that is confidently in the wrong place. Export GeoJSON instead.- KML
NetworkLinkandGroundOverlay, both of which fetch remote resources. - GPX
<rte>routes. Use<trk>. - Any XML that declares a DTD or an entity. Real KML and GPX files have no need of one, and hostile ones do.
- Polygon holes. The outer boundary draws; inner rings are ignored.
Size. Geodata is read up to about 400 KB, which covers an hour of phone GPS. Past that the file still previews as text but does not draw. A route denser than 10,000 points is thinned to fit, keeping the first and last positions. Beyond 500 features, the remainder is left out of the preview. Download the file for the whole of it.
Previews are per-attachment and open on demand, so a channel full of files costs nothing until someone expands one.
2. Post a map card to a webhook¶
A channel manager opens the channel's settings, mints an incoming webhook and copies the URL. The URL is the credential: anyone holding it can post to that channel, so treat it like a token and rotate it from the same panel if it leaks.
POST this and a map appears in the channel:
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"title": "Order #4821: Rotterdam to Felixstowe",
"client_msg_id": "order-4821",
"blocks": [{
"type": "map",
"spec": {
"title": "In transit",
"height": 320,
"legend": [{"label": "In transit", "color": "#0070F3"}],
"track": {
"points": [
{"lat": 51.9244, "lon": 4.4777, "t": "2026-07-24T09:00:00Z", "status": "loading", "title": "Rotterdam"},
{"lat": 51.9500, "lon": 3.8000, "t": "2026-07-24T11:30:00Z", "status": "tracking"},
{"lat": 51.9600, "lon": 2.4000, "t": "2026-07-24T15:10:00Z", "status": "tracking"},
{"lat": 51.9617, "lon": 1.3513, "t": "2026-07-24T18:45:00Z", "status": "tracking", "title": "Felixstowe"}
]
}
}
}]
}'
You get back the id of the message you just created:
{"ok": true, "id": "4f0c…", "seq": 812}
Updating it live¶
Send the same client_msg_id again with "mode": "update". The card is replaced
where it already sits, and every open client redraws. No second message, no
thread of near-identical updates:
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"title": "Order #4821: Rotterdam to Felixstowe",
"client_msg_id": "order-4821",
"mode": "update",
"blocks": [{"type": "map", "spec": {"title": "Arrived", "track": {"points": [ … ]}}}]
}'
{"ok": true, "id": "4f0c…", "seq": 812, "updated": true}
This is the supported way to keep a map current. Your data never leaves your network on a schedule we control, nothing is sent while nobody is watching, and there is no token in a browser and no CORS to arrange.
Rules worth knowing:
client_msg_idis required for an update. Without a key there is nothing to update, and the POST posts a new message.- An unknown key is a first post. You never have to special-case the opening
delivery: send
mode: updatefrom the very first call if that is simpler. mode: updatewith no card content is a400. Replacing a live card with nothing is almost always a caller that forgot its payload, so we refuse rather than blank the card.- A deleted message does not come back. An update against one is not an error; it just does not resurrect anything.
- Without
mode, repeats are ignored. A retry carrying a key that already posted returns the original message rather than duplicating it, which is what makes at-least-once delivery from your side safe.
Errors and rate limits¶
| Status | Means |
|---|---|
400 |
Body was not a JSON object, or carried neither text nor card content |
404 |
Unknown, inactive, or archived-channel webhook |
429 |
Too fast. Back off and retry |
A single webhook URL accepts 120 requests a minute. Updates to a card are broadcast to every reader exactly like a new post, so they are additionally limited to 30 a minute for that channel. For a moving vehicle, one update every few seconds is plenty; a map that redraws faster than a person can look at it is not more informative.
3. Let an agent answer with a map¶
Register your API once as an OpenAPI connection in workspace settings, with whatever token auth it already uses. From then on, someone can ask in chat:
@agent show me the active journeys in the Netherlands
The agent calls your endpoint, gets your data back, and answers with a map in the thread. Click a pin and the popup can link straight back into your own portal.
Two things make this worth the ten minutes it costs:
- Nothing about your domain enters JuhJuh. We know GeoJSON and the property names below. Journeys, assets, sites, routes and inspections are yours.
- It compounds. One registration answers every subsequent map question, including ones nobody thought of when it was set up.
Requests to your API go through an outbound guard that resolves and pins the host, so it must be a real public hostname. Private addresses are refused.
4. Paste a fenced block¶
In a ticket description, a ticket comment, a pull request description, a
knowledge page or a chat message, fence your GeoJSON as ```geojson (or
```map):
Depot coverage for the Q3 review:
```geojson
{
"title": "Depots",
"height": 260,
"data": {
"type": "FeatureCollection",
"features": [
{"type": "Feature",
"properties": {"title": "Rotterdam", "status": "open", "icon": "truck",
"url": "https://portal.example.com/depots/rtm"},
"geometry": {"type": "Point", "coordinates": [4.4777, 51.9244]}},
{"type": "Feature",
"properties": {"title": "Felixstowe", "status": "planned", "icon": "ship"},
"geometry": {"type": "Point", "coordinates": [1.3513, 51.9617]}}
]
}
}
```
Three shapes are accepted inside the fence, so you rarely have to reshape
anything: a bare GeoJSON document, a {"title": …, "data": …} spec, or a whole
{"type": "map", "spec": …} block copied out of a webhook payload.
- A fence takes
title,height,fit,cluster,legendanddata. - The
trackshortcut is not available here. Fences carry GeoJSON only. Use a webhook card if you want the shortcut, or convert your points first. - Limits are 500 features and 200,000 bytes of JSON. Over either, or on invalid JSON, the fence renders as an ordinary code block so you can see what you wrote and fix it.
There is also a location button in the message composer. It writes one of these fences into your draft with your current position in it, which you can read, edit or delete before sending.
The spec¶
{
"type": "map",
"spec": {
"title": "Order #4821", // up to 120 characters
"height": 320, // px, clamped to 160 to 640, default 320
"fit": "auto", // "auto", or [[west, south], [east, north]]
"cluster": false, // group dense points; click a group to zoom in
"legend": [{"label": "In transit", "color": "#0070F3"}], // up to 12 entries
// exactly one of:
"data": {"type": "FeatureCollection", "features": [ … ]},
"track": {"points": [{"lat": 51.9, "lon": 4.4, "t": "…", "status": "tracking"}]}
}
}
data accepts a FeatureCollection, a plain array of features, a single
Feature, or a bare geometry. All four end up as the same collection.
Geometry types: Point, MultiPoint, LineString, MultiLineString,
Polygon, MultiPolygon. GeometryCollection is not accepted. Express it as
sibling features, which draws identically.
There is no data_url. We do not fetch map data from a URL you supply.
Running a fetcher on a timer against addresses that arrive in a payload is a
liability we are not taking on, and we are not planning to add it. Send the data
in the card, update it with mode: update, or attach the file.
Feature properties¶
A feature's properties are filtered down to these nine names. Everything else
is discarded, which is what lets a popup render your text without becoming a way
to inject markup into someone else's browser.
| Property | Effect | Limit |
|---|---|---|
title |
Popup heading, and the name used in the card's text summary | 120 characters |
subtitle |
Popup body, first line | 160 characters |
description |
Popup body | 400 characters |
status |
Colours the feature from the workspace palette. Features sharing a value share a colour, and a chart in the same thread agrees with the map | 60 characters |
category |
The same colouring, for data that is not a status. status wins when both are set |
60 characters |
timestamp |
Carried on the feature. It is what the track shortcut writes from each point's t |
40 characters |
color |
Explicit colour, overriding the palette. #RRGGBB only. Colour names and short hex are dropped |
n/a |
icon |
Marker glyph: pin, truck, plane, ship, train, flag, warning. Anything else is dropped |
n/a |
url |
Click target on the popup. http(s) only, and checked again in the browser before it is followed |
n/a |
Segment colouring falls out of this. A journey with six legs is six LineString
features carrying six status values, in one collection. You never think about
layers or styles.
The track shortcut¶
Most producers have a list of timestamped positions, not GeoJSON. Send that instead:
{"type": "map", "spec": {"track": {"points": [
{"lat": 51.9244, "lon": 4.4777, "t": "2026-07-24T09:00:00Z", "status": "loading", "title": "Rotterdam"},
{"lat": 51.9500, "lon": 3.8000, "t": "2026-07-24T11:30:00Z", "status": "tracking"},
{"lat": 51.9617, "lon": 1.3513, "t": "2026-07-24T18:45:00Z", "status": "tracking", "title": "Felixstowe"}
]}}}
Each point takes lat, lon, t, and any of the properties above. t lands on
the feature as timestamp.
What comes back:
- Consecutive points join into routes, split wherever
statuschanges. A leg takes the status of the point it starts from, so a three-status journey draws as three differently coloured legs. - Legs join up. Each new leg starts at the previous point's coordinate, so a status change leaves no gap in the line.
- The first and last points also draw as markers, keeping their title, icon and status.
- A single point yields just its marker. That is a shared location, for free.
Your order is kept. Points draw in the order you sent them, and we never
re-sort on t. A timestamp that disagrees with arrival order is far more often a
bad clock than a shuffled journey, and silently redrawing someone's route on that
guess is not a call we are willing to make. Sort before you send if your source
is unordered.
Limits and what happens at them¶
Some limits reject the map. Others quietly skip a bad point and tell you they did. The difference is worth knowing before you debug a missing card.
Caps reject the whole map. Over any of these, the map block is dropped from the card rather than trimmed:
| Cap | Value |
|---|---|
| Features | 500 |
| Payload | 200 KB of JSON |
| Coordinate pairs | 10,000 |
A map that quietly shows half a journey reads as "here is everything", which is worse than no map. If you are over, send fewer features (simplify the geometry, or thin the track), or attach the file.
Bad data is dropped and counted. A single GPS glitch must not kill the card, so these are skipped and the total is reported on the card as "3 points skipped":
- Coordinates outside
lon ∈ [-180, 180]orlat ∈ [-90, 90] - Non-numeric, missing, infinite or boolean coordinates
- Features whose geometry type is not on the list above,
GeometryCollectionincluded
Two more parts get trimmed, both because the alternative renders as nothing at all: a route needs at least 2 positions and a polygon ring at least 3, so shorter parts are discarded. Elevation, and any third value in a position, is ignored. The map is two-dimensional.
Text is clamped to the lengths in the property table rather than rejected. A
title of 400 characters posts as the first 120.
Choosing a map provider¶
Maps draw on a background map, and the background map is the part that costs money. Workspace settings has a Map provider section with three fields:
| Field | What to put in it |
|---|---|
| Map style URL | An https vector style URL from your provider |
| Map style URL (dark mode) | Optional. Blank uses the style above in both themes |
| Allow live map feeds | Off by default. See below |
With nothing configured, maps still work: they fall back to shared community tiles, and workspace settings says so in a hint that does not go away. Those tiles are not for production use. Their usage policy rules out heavy commercial traffic, and a busy workspace on them is quietly spending someone else's bandwidth. Any provider publishing a standard vector style URL works: MapTiler, Protomaps, Stadia, a self-hosted style, or your own account with a commercial provider.
If no tile source is reachable at all, whether that is an air-gapped deployment or a provider outage, geometry still plots on a plain background. A tile problem never takes a thread down with it.
Live feeds are the polling option: a map that refreshes itself from a data
URL you publish. Add a feed to the spec:
{
"type": "map",
"spec": {
"title": "Fleet — live",
"feed": {
"url": "https://api.example.com/fleet/live.geojson",
"interval": 15,
"mode": "replace"
}
}
}
| Field | Required | Notes |
|---|---|---|
url |
yes | Must be https. Returns the same GeoJSON a data value would. |
interval |
no | Seconds between refreshes. Minimum 10, default 30. Anything lower is raised to 10. |
mode |
no | replace swaps the whole collection. append adds to the existing one, dropping the oldest points once the limits are reached. Default replace. |
It refreshes only while the card is on screen and the tab is focused, and stops after three consecutive failures with a Retry button rather than hammering your endpoint. One live map refreshes per view; older ones offer a manual Refresh.
Two conditions, both yours to arrange:
- The workspace has to turn live feeds on. They are off by default.
- Your endpoint has to be publicly readable and send CORS headers allowing the workspace's origin. The browser fetches it directly, so there is nothing we can do from our side if it does not.
Where you can choose, prefer mode: update on a webhook. It needs neither
condition, it works for private data because your server pushes rather than the
browser pulling, and it sends nothing when nobody is looking.
Accessibility, search and email¶
Every map ships a text alternative, generated from the same data as the picture:
Map: Order #4821 — route, 12 points, Rotterdam → Felixstowe
That line is what a screen reader announces, what a notification email carries
when someone is offline, and what the message search index holds. Features
carrying a url also become a real list of links, visually clipped but reachable
by keyboard, so a map is navigable without a mouse or a working WebGL context.
The practical consequence: give your features a title. An untitled
collection is announced as "12 locations", which is true and useless. A titled one
names the places.