Public API
A small HTTP API for building maps programmatically: export a map as GraphML, change it (in a script, by hand, or with an AI agent), and import it back. Imports upsert: they update what’s already there instead of duplicating it. The same pipeline can run against a map again and again.
Every endpoint creates or updates; nothing deletes. To remove nodes, connections, fields or maps, use the editor. Sending the same request twice is safe: the second changes nothing.
The OpenAPI 3.1 document is at GET /api/v1/openapi.json, with every endpoint’s
parameters and request body. It needs no key. Load it into an API client, or give it to an
AI agent. How the API is built is in
Public API internals.
Endpoints
Section titled “Endpoints”| Endpoint | Does |
|---|---|
GET /api/v1/maps |
The organisation’s maps. |
POST /api/v1/maps |
Get or create a map by name. |
PATCH /api/v1/maps/:mapId |
Rename a map. |
GET /api/v1/maps/:mapId/fields |
The map’s measures, tags and connection types. |
POST /api/v1/maps/:mapId/fields |
Get or create a measure, tag or connection type in the map by name. |
PATCH /api/v1/maps/:mapId/fields/:fieldId |
Rename, describe, regroup a field, change a connection type’s direction, or name its value. |
POST /api/v1/maps/:mapId/changes |
Create or update nodes and connections from JSON. |
GET /api/v1/maps/:mapId/export.graphml |
The map as GraphML, with each node’s external_key. |
POST /api/v1/maps/:mapId/import |
Upsert a GraphML (or internal-JSON) file into the map. |
GET /api/v1/maps/:mapId/recipe |
The map’s recipe, and whether its data has changed since it was confirmed. |
PUT /api/v1/maps/:mapId/recipe |
Write the recipe. |
POST /api/v1/maps/:mapId/recipe/validate |
Confirm the recipe still describes the map’s data. |
GET /api/v1/maps/:mapId/views |
The map’s saved views, steps first, each described in words. |
GET /api/v1/maps/:mapId/views/parameters |
What a view’s parameters can hold, and the map’s tags, tag groups, measures and connection types by name. |
POST /api/v1/maps/:mapId/views |
Get or create a view by name, and change it. |
GET / PATCH /api/v1/maps/:mapId/views/:viewId |
Read or change one view. |
GET /api/v1/openapi.json |
This API as an OpenAPI document. No key needed. |
A map’s id is in its URL in the app (/maps/<mapId>), or returned by
GET/POST /api/v1/maps.
A key acts for one member of one organisation. It can read and write every map in that organisation, as that member could. It stops working as soon as the member is removed or loses their role, or the organisation is deleted.
The API calls measures, tags and connection types fields.
Mint one from a checkout, with the project’s .env in place (there is no UI for this yet):
./scripts/supa pnpm api-key:create --org <organisation uuid> --user <member email>It prints the key (mmk_…) once, plus its id. Only a hash is stored, so a
lost key can’t be recovered; mint another. The script talks to whichever
project .env points at (the shared dev project by default). For another
project, set VITE_SUPABASE_URL and SUPABASE_SECRET_KEY for that project
yourself.
To revoke a key, delete its row (the id was printed when it was minted):
delete from app.api_key where id = '<key id>';Keep keys out of shell history and files you commit:
export MMK_API_KEY='mmk_…'export MM_URL='http://localhost:3000' # or the deployed app's URLexport MAP_ID='…'Every request sends it as a bearer token:
-H "Authorization: Bearer $MMK_API_KEY"# Get the map called "Funding landscape", creating it if there isn't one.curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ -d '{"name": "Funding landscape"}' "$MM_URL/api/v1/maps"The response is 201 { "created": true, "map": {…} } for a new map. If a map
with exactly that name already exists, it’s 200 { "created": false, "map": {…} }, and the oldest one is returned if there are several. A
pipeline can therefore start with this call every time.
GET /api/v1/maps lists them. PATCH /api/v1/maps/$MAP_ID with {"name": "…"}
renames one.
Fields: measures, tags and connection types
Section titled “Fields: measures, tags and connection types”Every map has its own measures, tags and connection types. A new map has none, and a field in one map is never visible from another: create the ones a map needs in that map.
# Ensure a directed connection type that records an amount in GBP exists.curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ -d '{"kind": "connection_type", "name": "Funds", "value_type": "numeric", "directionality": "directed", "value_label": "Amount", "value_unit": "GBP"}' \ "$MM_URL/api/v1/maps/$MAP_ID/fields"
# A tag in an exclusive group (the group is created if it's new).curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ -d '{"kind": "tag", "name": "funder", "group": "Org type"}' "$MM_URL/api/v1/maps/$MAP_ID/fields"
# A measure.curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ -d '{"kind": "measure", "name": "Members"}' "$MM_URL/api/v1/maps/$MAP_ID/fields"kindismeasure(a number on a node),tag(a node has it or doesn’t; tags in agroupare exclusive, so a node has at most one), orconnection_type.- Any field can take a
description; a measure can also take acategory. - A tag can take an
icon(User,Users,Building2,Landmark,Briefcase,Globe,Newspaper,HeartHandshake,FolderKanban,MapPin, ornone) and acolour(#2563eb,#16a34a,#d97706,#dc2626,#9333ea,#0891b2,#db2777or#65a30d).null, or leaving them out, means the default: the next colour along in its group and an icon guessed from its name. - Connection types take
value_type(boolean, the default, ornumeric),directionality(undirected, the default,directedormutual) and, when directed, an optionalinverse_namesuch as"funded by".numericmeans each connection of the type can record a number, such as a grant’s amount. It’s optional: a connection with no value is still a complete connection of that type.- A numeric type can say what its value is and what it’s measured in:
value_label(e.g."Amount") andvalue_unit(e.g."GBP"; a three-letter currency code is shown as money in the app). Giving either to a boolean type is400 invalid_request.
- Get or create. Like maps,
POSTreturns an existing field in the map of the same kind and name, compared case-insensitively (the oldest, if there are several). A connection type that exists with a differentvalue_typeordirectionalitygets409 conflictrather than a silent mismatch. GET /api/v1/maps/$MAP_ID/fields[?kind=…]lists them — every field the map has, used or not. Each has anid, which/changesand GraphML’sfield:<id>keys accept in that map, pluskind,name,description,valueType,category(a tag’s group),subjectType,directionality,inverseName,valueLabel,valueUnitandcreatedAt. A tag also hastagGroupId,iconandcolour.PATCH /api/v1/maps/$MAP_ID/fields/$FIELD_IDtakesname,description,group(a tag’s group;nullungroups it),category(a measure’s), a tag’siconandcolour,directionality,inverse_name,value_labelandvalue_unit(an empty string ornullclears them).- A field id that isn’t in this map is
404 not_found, even if it exists in another map. value_type: only a connection type starting to record a value ("numeric") is allowed. Its connections keep existing, with no value yet. Anything that throws values away (a connection type stopping, or a measure becoming a tag) is400 invalid_request: use the editor.- Regrouping tags so that a node would end up with two tags from one group
is
409 conflict.
- A field id that isn’t in this map is
Changes: nodes and connections as JSON
Section titled “Changes: nodes and connections as JSON”POST /api/v1/maps/:mapId/changes is the easiest way to write to a map from a
script. It uses the same upsert as /import (see
How upsert works), but the body is plain JSON, and you
name things the way you think about them:
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ "$MM_URL/api/v1/maps/$MAP_ID/changes" -d @- <<'JSON'{ "nodes": [ { "key": "id:acme", "label": "Acme Trust", "properties": { "url": "https://acme.example" }, "measures": { "Members": 40 }, "tags": ["funder"] }, { "key": "id:union", "label": "Tenants Union" } ], "connections": [ { "source": "id:acme", "target": "id:union", "type": "Funds", "value": 5000, "properties": { "year": "2024" } } ]}JSONNodes
- Every node needs a
key(itsexternal_key) or anid(the uuid of a node already in the map). New nodes usekeyand need alabel. propertieskeep their JSON types.measuresmaps a measure’s name or id to a number.tagslists tag names or ids.- As with import, only what you send changes, and a node’s tags from an exclusive group replace each other.
Connections
sourceandtargetare a node’skeyorid: either one in this request, or one already in the map, which needs no stub.typeis a connection type’s name or id. Itsvalueis a number for a numeric type, and optional: leave it out (or sendnull) and the connection is still complete, just with no value. On an existing connection, leavingvalueout (ornull) keeps the value it has: a value can’t be cleared through the API (use the editor). For a boolean type,trueor leave it out (falsemeans don’t create it).- Omit
typefor an untyped connection. Put a note inproperties.reason. - Dates (optional, on any connection):
occurred_on(when it happened, e.g. awarded),valid_fromandvalid_to(the period it holds; leavevalid_toout for ongoing). Each is a year, a month or a day —"2024","2024-06"or"2024-06-15"— and how much you write is its precision. Only the dates you send change;nullclears one. A date that isn’t one, or a period that ends before it starts, is a400 invalid_document. Dates never affect which connection is matched, so re-sending a connection with a corrected date updates it. - Pass an existing connection’s
idto update that one in particular. Otherwise matching is by endpoints and type, as import does it.
Fields are only referenced, never created here. An unknown or ambiguous
field name is a 400 invalid_document whose details say which one (for
example no connection type "Funds" in this map; create it first (POST /api/v1/maps/{mapId}/fields)); create it in the map first. The response and history are the same as
an import’s.
Export
Section titled “Export”curl -sS -H "Authorization: Bearer $MMK_API_KEY" \ "$MM_URL/api/v1/maps/$MAP_ID/export.graphml" -o map.graphmlThis is the same GraphML the editor’s Export button produces. It includes:
external_keyon every node that has one:<data key="external_key">…</data>.- Connection dates on every connection that has them:
<data key="occurred_on">2023-10-06</data>,valid_from,valid_to, each a year, month or day, as in/changes. An import reads the same keys; a value that isn’t a date is kept as a property. - Node ids are the nodes’ database ids and edge ids are the connections’ database ids. Leave them as they are and a re-import updates those same nodes and connections.
- Measures, tags and connection types are keyed
field:<uuid>. Keep those keys and, imported back into the same map, the values bind straight back to the same fields. Another map doesn’t have those fields, so there they go through the rules under Fields are only created if you ask. - Undefined connections (no type) aren’t exported, as in the editor.
Import
Section titled “Import”curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \ --data-binary @map.graphml \ "$MM_URL/api/v1/maps/$MAP_ID/import"The request body is the file: use --data-binary, not -F (a multipart
upload is refused). The limit is 10 MB. The default format is GraphML; add
?format=json to send the internal-JSON format the editor exports instead.
How upsert works
Section titled “How upsert works”Nodes are matched in this order:
- By
external_key— a node in this map with the same key. Keys are case-sensitive, trimmed, 1–255 characters, and must be unique within the file. A blank key counts as no key. - By id — the file’s node id is the id of a node in this map. This is what makes export → edit → import work for nodes that were drawn in the editor and have no key.
- Otherwise a new node, with a new id. The file’s id is never reused, so importing one map’s export into another map makes copies.
If a node matched by id has no key and the file gives it one, it takes the
key. If it already has a different key, it keeps its key and the node is
listed under conflicts in the response. If the key and the id point at
different nodes, the key wins and that’s reported too.
For a matched node, only what the file supplies changes:
- The label changes only if the file has a label for the node.
- Properties are merged: keys in the file overwrite, others are kept.
- Measures in the file are set; an empty value means “not given”.
- Tags in the file are added. A
falsetag is ignored, since a tag is either present or absent. The one exception to never deleting: adding a tag from an exclusive group (such as a “Type” group) replaces the node’s other tag in that group. It’s counted invalues.tagsReplaced.
New nodes arrive unplaced: they’re in the Nodes panel’s Unplaced list, not on the canvas. Positions are never changed by an import.
Connections are matched by their two (resolved) nodes plus what the
connection means. That is its connection type, or for an untyped
connection its note (the reason property). For an undirected or mutual type
either direction matches; a directed type matches only as drawn. Each existing
connection is matched at most once per import. The file’s edge id is
preferred when it’s one of this map’s connection ids; otherwise the oldest
unmatched connection is used. Anything left over is created. The file’s value
and properties win; an empty value means “not given”. One GraphML edge
carrying several types becomes one connection per type.
Nothing is deleted. Nodes, connections, properties, measures and tags that aren’t in the file are left alone. To remove something, use the editor.
Import the same file twice and the second import changes nothing. That
holds when every node can be matched: it has an external_key, or its id is
this map’s own (as in an export). A third-party file with neither is not
idempotent. Its ids (n0, n1, …) match nothing, so each import creates
the nodes again. Give every node an external_key.
To connect a new node to one that’s already in the map, include the existing node as a stub. It updates nothing:
<node id="3f1c…-existing-node-uuid"/><node id="new"><data key="label">New grantee</data><data key="external_key">id:new-grantee</data></node><edge source="3f1c…-existing-node-uuid" target="new"><data key="field:<type uuid>">5000</data></edge>An edge may only reference nodes in the same file, so an existing node needs its stub.
Fields are only created if you ask. An attribute that matches one of
this map’s measures, tags or connection types (by its field:<uuid> key, or
by name) binds to it. By default, anything else is stored as a plain property and listed in
unmatchedFieldKeys.
Add ?create_fields=true to create what’s missing, the way the editor’s
import does: a numeric attribute becomes a measure, a true/false one a tag (or
a connection type, on edges), and text stays a property. The file is checked
first, so a rejected import creates nothing, and a second run reuses the
fields by name. The response lists them in fieldsCreated.
History. Every accepted import or /changes call is recorded in the map’s
history as a single change (map.imported), attributed to the key’s member,
even one that changed nothing. When it changed something, the uploaded file is
stored with it. A rejected call records nothing. There’s no revision UI yet.
Each recorded call marks the map’s recipe out of date.
Imports into one map run one at a time. An import isn’t one database transaction. If one fails part-way, re-run it: with keys, it picks up where it left off rather than duplicating.
Response
Section titled “Response”{ "changed": true, "format": "graphml", "nodes": { "created": 12, "updated": 3, "unchanged": 40 }, "connections": { "created": 20, "updated": 1, "unchanged": 75, "skipped": 0 }, "values": { "measuresSet": 4, "tagsAdded": 2, "tagsReplaced": 0 }, "unmatchedFieldKeys": ["notes"], "conflicts": { "count": 0, "items": [] }, "skipped": { "count": 0, "items": [] }, "fieldsCreated": [], "importSessionId": "…", "changeId": "…", "warnings": []}changed: falsemeans nothing was written to the map.importSessionIdis thennull, andchangeIdstill names the recorded change.conflicts.itemshas kindexternal_key_mismatchorid_and_key_disagree.skipped.itemslistsself_loop(an edge from a node to itself),false_only(an edge whose only type was afalse), andvanished(a row deleted in the editor while the import ran).- Both lists are capped at 100 items; the counts aren’t.
Recipe
Section titled “Recipe”A map’s recipe is a markdown note on how its data was made: sources, steps, what was left out. The app shows it under About this map (see Managing maps). A pipeline can write it after pushing the data:
curl -sS -X PUT -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ -d '{"body_md": "# Sources\n\n- 360Giving grants, 2019 to 2024", "validated": true}' \ "$MM_URL/api/v1/maps/$MAP_ID/recipe"GET …/recipe and POST …/recipe/validate return { "recipe": {…} }. PUT also
returns changed:
{ "changed": true, "recipe": { "body_md": "# Sources\n\n- 360Giving grants, 2019 to 2024", "updated_by": { "id": "…", "name": "Sam Organiser" }, "updated_at": "2026-10-05T17:25:04.123456+00:00", "validated_by": { "id": "…", "name": "Sam Organiser" }, "validated_at": "2026-10-05T17:25:04.123456+00:00", "data_changed_at": "2026-10-05T17:24:58.654321+00:00", "stale": false }}body_mdis at most 20,000 characters. A map with no recipe returns an emptybody_mdandnullpeople and times. To clear a recipe,PUTan emptybody_md.PUTtakes onlybody_mdandvalidated. Any other key is400 invalid_request. The last write wins.changed: falsemeans nothing was written.validated: trueconfirms the recipe against the data as it is now, so push the data first. Without it, a new recipe is saved against the current data with no one named invalidated_by, and an existing recipe’s confirmation is left as it was.POST …/recipe/validateconfirms the recipe without changing its text. A map with no recipe is404 not_found.staleistruewhen the map’s data changed after the recipe was last confirmed, or first written.data_changed_atis when the data last changed, ornull. Every/changesand/importcall counts as a change, and so does aPATCHthat starts a connection type recording a value. Other field edits don’t.- People are the key’s member for API writes.
nameis their display name, else their email, else “a former member”.
A saved view is a way of looking at a map in Visualise mode. Its parameters are the
display settings: the mode, size and colour, what the map is drawn from, the filter. A
view with a step (1, 2, 3, …) is a step in a
progressive reveal: the map
built up over several views, each leaving some nodes and connections out.
Start by reading what parameters can hold:
curl -sS -H "Authorization: Bearer $MMK_API_KEY" "$MM_URL/api/v1/maps/$MAP_ID/views/parameters"It returns schema (JSON Schema, with a description of every key), defaults, and
vocabulary: the map’s tags, tag_groups, measures and connection_types, each
{ id, name }. Wherever a parameter names one of those, send its id or its name
(compared case-insensitively). Ids are what’s stored.
Then make sure a view exists and set it up. This is the third step of a reveal: the map without connections between the untagged node (“us”) and funders, and without the funders that leaves unconnected.
curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" -H "Content-Type: application/json" \ "$MM_URL/api/v1/maps/$MAP_ID/views" -d @- <<'JSON'{ "name": "Our clients' funders", "step": 3, "parameters": { "connection_exclusions": [ { "from": { "tags": [{ "op": "none", "tagIds": ["client", "funder"] }] }, "to": { "tags": [{ "op": "any", "tagIds": ["funder"] }] } }, { "from": { "tags": [{ "op": "any", "tagIds": ["funder"] }] }, "to": { "tags": [{ "op": "none", "tagIds": ["client", "funder"] }] } } ], "leave_out_unconnected": true }}JSONPOST …/viewstakesname, and optionallystep,parametersandreset. A view with that name (compared case-insensitively) is changed and returned with200; otherwise one is created from the defaults with201. Two views sharing the name is409 conflict: change one by id.PATCH …/views/$VIEW_IDtakesname,step,parametersandreset, all optional.- Parameters change key by key. Each key you send replaces that key. Keys you don’t
send stay as they are.
nullsets a key back to its default."reset": truestarts from the defaults."step": nulltakes a view out of the reveal. - Responses are
{ "view": {…} }, pluscreated(POST) andchanged. A view hasid,name,step,parameters,viewport,created_at,updated_atanddescription: what the view shows, in the words of the app’s About this map panel. Read it back to check the view says what you meant. - Sending the same request again changes nothing and returns
"changed": false. - A view made through the API has no saved camera: the app fits the map when it opens. A map open in the app picks up a change to the view it’s showing when the window regains focus, unless someone there has unsaved changes.
Errors
Section titled “Errors”Every error has one shape:
{ "error": { "code": "invalid_document", "message": "…", "details": [{ "path": "nodes[3].label", "message": "Label is required." }] } }| Status | code |
When |
|---|---|---|
| 400 | invalid_request |
Bad query (format, create_fields, kind), a /maps, /fields, /recipe or /views body that fails validation (including value_label/value_unit on a boolean type, a lossy value_type change, a recipe over 20,000 characters, or a view parameter naming a tag or type the map doesn’t have, which lists the ones it does), a body that isn’t JSON, multipart or compressed body, empty body, not UTF-8 |
| 400 | invalid_document |
The import file can’t be parsed, or the file or /changes body can’t be applied. details lists every problem found, and nothing is written. |
| 401 | unauthorized |
Missing, malformed or unknown key |
| 403 | forbidden |
The map belongs to another organisation, or the key’s member no longer has access |
| 404 | not_found |
No such map (or the id isn’t a uuid), a field or view id that isn’t in this map, or a recipe to validate on a map that has none |
| 405 | method_not_allowed |
That method isn’t supported on this endpoint (any DELETE, for now); the Allow header lists the ones that are |
| 409 | conflict |
A connection type exists with a different value type or direction, a tag regroup would give a node two tags from one group, or two views share the name a POST …/views gives |
| 413 | payload_too_large |
Body over 10 MB (import, /changes) or 1 MB (/maps, /fields, /recipe, /views) |
| 500 | internal_error |
Something went wrong on our side |
The set of codes may still grow.
Round trip, end to end
Section titled “Round trip, end to end”# 1. Export.curl -sS -H "Authorization: Bearer $MMK_API_KEY" \ "$MM_URL/api/v1/maps/$MAP_ID/export.graphml" -o map.graphml
# 2. Edit map.graphml: rename a node, add a keyed node and an edge to it.
# 3. Import. Expect "changed": true.curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \ --data-binary @map.graphml "$MM_URL/api/v1/maps/$MAP_ID/import"
# 4. Import again. Expect "changed": false and nothing created or updated.curl -sS -X POST -H "Authorization: Bearer $MMK_API_KEY" \ --data-binary @map.graphml "$MM_URL/api/v1/maps/$MAP_ID/import"Then open the map: the renamed node shows its new label, and the new node is in the Nodes panel’s Unplaced list.